mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
+70
-183
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+18
-1
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user