refactor(cashu): migrate token-redeem melt to NUT-05

MeltProcessor (the "Redeem received cashu token → my Lightning address"
button) was hand-coded against the deprecated pre-v1 Cashu API (POST /melt
and POST /checkfees with {pr, proofs}). Those endpoints are gone on CDK and
other modern mints, and the path never accounted for NUT-02 per-input fees,
so it failed on fee-charging keysets the same way the wallet melt did.

Route it through the same NUT-05 CashuMintOperations the NIP-60 wallet uses:
requestMeltQuote + meltProofs. A probe quote at the full token value reveals
the LN fee_reserve, to which we add inputFeeFor(proofs) before fetching the
real invoice for (total − fees) and melting. No change is requested — there
is no wallet to hold leftover proofs, so the unused reserve stays with the
mint, matching the legacy behavior.

Supporting changes in CashuMintOperations:
- meltProofs gains requestChange (default true) so the redeem path can melt
  without minting orphan change outputs.
- inputFeeFor(proofs) exposes the per-keyset NUT-02 fee for invoice sizing.

Also drops the dead empty melt(...) overload that was a stub with a TODO.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Ffjz3doZ5CtFtAWSpvmqR
This commit is contained in:
Claude
2026-06-17 21:42:31 +00:00
parent d9a9ecdc05
commit 2cbdfe749b
2 changed files with 88 additions and 184 deletions
@@ -21,22 +21,30 @@
package com.vitorpamplona.amethyst.service.cashu.melt
import android.content.Context
import com.fasterxml.jackson.databind.node.JsonNodeFactory
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip60Cashu.CashuToken
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.asTextOrNull
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import com.vitorpamplona.quartz.nip60Cashu.mintApi.CashuMintOperations
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintHttpClient
import com.vitorpamplona.quartz.nip60Cashu.token.CashuProof
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.coroutines.executeAsync
import kotlin.coroutines.cancellation.CancellationException
/**
* Redeems an externally-received cashu token straight to a Lightning address
* via NUT-05 melt (`POST /v1/melt/quote/bolt11` + `POST /v1/melt/bolt11`).
*
* This is the "Redeem" button on a received cashu token preview — distinct
* from the NIP-60 wallet's own send-LN flow ([com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletOps.meltToLightning]),
* which spends the user's stored proofs and rolls change back into the wallet.
* Here there is no wallet: the proofs come straight off the pasted token and
* any leftover stays with the mint.
*
* Migrated off the deprecated pre-v1 `/melt` + `/checkfees` endpoints (gone on
* CDK and other modern mints) onto the same NUT-05 client the wallet uses, so
* it also picks up NUT-02 per-input fee handling for free.
*/
class MeltProcessor {
suspend fun melt(
token: CashuToken,
@@ -44,189 +52,68 @@ class MeltProcessor {
okHttpClient: (String) -> OkHttpClient,
context: Context,
): MeltResult {
val baseInvoice =
LightningAddressResolver().lnAddressInvoice(
lnAddress = lud16,
// Make invoice and leave room for fees
milliSats = token.totalAmount * 1000,
message = "Calculate Fees for Cashu",
okHttpClient = okHttpClient,
onProgress = {},
context = context,
)
val fees =
feeCalculator(
mintAddress = token.mint,
invoice = baseInvoice,
okHttpClient = okHttpClient,
context = context,
)
val invoice =
LightningAddressResolver().lnAddressInvoice(
lnAddress = lud16,
// Make invoice and leave room for fees
milliSats = (token.totalAmount - fees) * 1000,
message = "Redeem Cashu",
okHttpClient = okHttpClient,
onProgress = {},
context = context,
)
meltInvoice(token, invoice, okHttpClient, context)
return MeltResult(
token = token,
invoice = invoice,
fees = fees,
)
}
fun melt(
token: CashuToken,
lud16: String,
okHttpClient: (String) -> OkHttpClient,
onSuccess: (String, String) -> Unit,
onError: (String, String) -> Unit,
context: Context,
) {
// TODO: Implement Cashu token melting via Lightning invoice
}
suspend fun feeCalculator(
mintAddress: String,
invoice: String,
okHttpClient: (String) -> OkHttpClient,
context: Context,
): Int =
try {
val url = "$mintAddress/checkfees" // Melt cashu tokens at Mint
val client = okHttpClient(url)
val factory = JsonNodeFactory.instance
val jsonObject = factory.objectNode()
jsonObject.put("pr", invoice)
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = jsonObject.toString().toRequestBody(mediaType)
val request =
Request
.Builder()
.url(url)
.post(requestBody)
.build()
client.newCall(request).executeAsync().use { response ->
withContext(Dispatchers.IO) {
val body = response.body.string()
val tree = jacksonObjectMapper().readTree(body)
val feeCost = tree?.get("fee")?.asInt()
if (feeCost == null) {
val msg =
tree
?.get("detail")
?.asTextOrNull()
?.split('.')
?.getOrNull(0)
?.ifBlank { null }
throw LightningAddressResolver.LightningAddressError(
stringRes(context, R.string.cashu_failed_redemption),
if (msg != null) {
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, msg)
} else {
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg)
},
)
}
feeCost
val ops = CashuMintOperations(MintHttpClient(token.mint, okHttpClient))
val proofs =
token.proofs.map {
CashuProof(id = it.id, amount = it.amount.toLong(), secret = it.secret, c = it.C)
}
// A Lightning address must commit to an amount before we know the
// fees, so probe with an invoice for the full token value to learn
// the LN fee_reserve, then add the NUT-02 input fee the mint
// charges on these proofs.
val probeInvoice =
LightningAddressResolver().lnAddressInvoice(
lnAddress = lud16,
milliSats = token.totalAmount * 1000,
message = "Calculate Fees for Cashu",
okHttpClient = okHttpClient,
onProgress = {},
context = context,
)
val probeQuote = ops.requestMeltQuote(probeInvoice)
val fees = probeQuote.feeReserve + ops.inputFeeFor(proofs)
val sendable = token.totalAmount - fees
if (sendable <= 0) {
throw LightningAddressResolver.LightningAddressError(
stringRes(context, R.string.cashu_failed_redemption),
stringRes(
context,
R.string.cashu_failed_redemption_explainer_error_msg,
"Token value ${token.totalAmount} does not cover fees $fees",
),
)
}
// Real invoice for (total fees), quote it, then melt without
// requesting change — there is no wallet to hold leftover proofs,
// so the unused fee_reserve stays with the mint.
val invoice =
LightningAddressResolver().lnAddressInvoice(
lnAddress = lud16,
milliSats = sendable * 1000,
message = "Redeem Cashu",
okHttpClient = okHttpClient,
onProgress = {},
context = context,
)
val quote = ops.requestMeltQuote(invoice)
ops.meltProofs(quote, proofs, requestChange = false)
return MeltResult(
token = token,
invoice = invoice,
fees = fees.toInt(),
)
} catch (e: Exception) {
if (e is CancellationException) throw e
if (e is LightningAddressResolver.LightningAddressError) throw e
throw LightningAddressResolver.LightningAddressError(
stringRes(context, R.string.cashu_failed_redemption),
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, e.message),
)
}
private suspend fun meltInvoice(
token: CashuToken,
invoice: String,
okHttpClient: (String) -> OkHttpClient,
context: Context,
) {
try {
val url = token.mint + "/melt" // Melt cashu tokens at Mint
val client = okHttpClient(url)
val factory = JsonNodeFactory.instance
val jsonObject = factory.objectNode()
jsonObject.replace(
"proofs",
factory.arrayNode(token.proofs.size).apply {
token.proofs.forEach {
addObject().apply {
put("amount", it.amount)
put("id", it.id)
put("secret", it.secret)
put("C", it.C)
}
}
},
)
jsonObject.put("pr", invoice)
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = jsonObject.toString().toRequestBody(mediaType)
val request =
Request
.Builder()
.url(url)
.post(requestBody)
.build()
client.newCall(request).executeAsync().use { response ->
withContext(Dispatchers.IO) {
val body = response.body.string()
val tree = jacksonObjectMapper().readTree(body)
val successful = tree?.get("paid")?.asText() == "true"
if (!successful) {
val msg =
tree
?.get("detail")
?.asTextOrNull()
?.split('.')
?.getOrNull(0)
?.ifBlank { null }
throw LightningAddressResolver.LightningAddressError(
stringRes(context, R.string.cashu_failed_redemption),
if (msg != null) {
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, msg)
} else {
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg)
},
)
}
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
throw LightningAddressResolver.LightningAddressError(
stringRes(context, R.string.cashu_successful_redemption),
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, e.message),
)
}
}
}
@@ -300,6 +300,14 @@ class CashuMintOperations(
return computeInputFee(splitAmounts(amount).size, keyset.inputFeePpk)
}
/**
* NUT-02 input fee the mint would charge to spend [proofs], priced per
* each proof's own keyset (active or inactive). Lets a caller size an LN
* invoice / target amount so the subsequent [meltProofs] or [swap] isn't
* left short by the fee.
*/
suspend fun inputFeeFor(proofs: List<CashuProof>): Long = computeInputFee(proofs, fetchInputFeePpkByKeyset())
/**
* Pay the bolt11 invoice. Spends [inputs], which must total at least
* `quote.amount + quote.fee_reserve`. Any change is returned blinded so the
@@ -308,6 +316,15 @@ class CashuMintOperations(
suspend fun meltProofs(
quote: MeltQuoteBolt11ResponseDto,
inputs: List<CashuProof>,
/**
* NUT-08: whether to send blinded change outputs so the mint can
* return the unused portion of the fee_reserve as fresh proofs.
* The NIP-60 wallet keeps this on (default) and rolls the change
* into a new kind:7375. A one-shot redemption with nowhere to store
* proofs (e.g. melting a received token straight to a Lightning
* address) sets it false and lets the mint keep the unused reserve.
*/
requestChange: Boolean = true,
): MeltResult {
val total = inputs.sumOf { it.amount }
val keyset = fetchKeyset()
@@ -326,7 +343,7 @@ class CashuMintOperations(
// excludes the (already-paid) NUT-02 input fee.
val changeAmount = total - quote.amount - inputFee
val changeOutputs =
if (changeAmount > 0) secretOutputsFor(splitAmounts(changeAmount), keyset) else emptyList()
if (requestChange && changeAmount > 0) secretOutputsFor(splitAmounts(changeAmount), keyset) else emptyList()
val response =
client.meltBolt11(