From d9a9ecdc058b24b46bd678f115a8bf096cfb6b52 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 21:28:50 +0000 Subject: [PATCH] fix(cashu): reserve the melt's NUT-02 input fee in send-LN swap-down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit meltToLightning selects proofs covering amount+fee_reserve and, on overshoot, swaps them down to exactly that before melting. But the melt mints its inputs on the active keyset and the mint then charges its own NUT-02 input fee on them — which the swap-down target didn't include. On a fee-charging mint (mint.coinos.io active keyset = 100 ppk) the melt was left a sat short and threw "Inputs total X < required Y", so fixing the per-keyset swap fee alone just moved the failure from the swap to the melt. Reserve activeKeysetInputFeeFor(required) on top of amount+fee_reserve for both proof selection and the swap-down target so the subsequent melt has room for its input fee. Also fix the reported fee in MeltCompleted: the pre-paid swap "keep" was split off before the melt and never spent, so subtract it instead of counting the whole selected total minus change (which overstated fees by the keep amount in the swap path). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012Ffjz3doZ5CtFtAWSpvmqR --- .../model/nip60Cashu/CashuWalletOps.kt | 27 ++++++++++----- .../nip60Cashu/mintApi/CashuMintOperations.kt | 17 ++++++++++ .../nip60Cashu/mintApi/NutTwoInputFeeTest.kt | 34 +++++++++++++++++++ 3 files changed, 70 insertions(+), 8 deletions(-) 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 ee9e9f50ff..4bec36368a 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 @@ -409,15 +409,22 @@ class CashuWalletOps( val ops = ops(mintUrl) val required = quote.amount + quote.feeReserve + // The melt mints its inputs on the active keyset (via the swap-down + // below) and the mint then charges a NUT-02 input fee on them on top + // of amount + fee_reserve. Reserve it up front so neither proof + // selection nor the swap-down leaves the melt short — otherwise + // meltProofs throws "Inputs total X < required Y" the moment the + // active keyset charges a per-input fee (e.g. mint.coinos.io). + val target = required + ops.activeKeysetInputFeeFor(required) - val (selected, _) = selectProofsCovering(available, required) + val (selected, _) = selectProofsCovering(available, target) val spendingProofs = selected.flatMap { it.content.proofs } val total = spendingProofs.sumOf { it.amount } - // If the selected proofs overshoot, swap them down to (required) first. - val (inputs, prePaidChangeEvent) = - if (total > required) { - val swap = ops.swap(spendingProofs, targetSplit = required) + // If the selected proofs overshoot, swap them down to (target) first. + val (inputs, prePaidChangeEvent, prePaidChangeAmount) = + if (total > target) { + val swap = ops.swap(spendingProofs, targetSplit = target) // The keep-side from the pre-swap is the "extra change" we // didn't burn into the melt — publish it now so a crashed // melt doesn't lose those proofs. @@ -431,9 +438,9 @@ class CashuWalletOps( } else { null } - swap.send to keepEvent + Triple(swap.send, keepEvent, swap.keep.sumOf { it.amount }) } else { - spendingProofs to null + Triple(spendingProofs, null, 0L) } val meltResult = ops.meltProofs(quote, inputs) @@ -485,7 +492,11 @@ class CashuWalletOps( return MeltCompleted( preimage = meltResult.preimage, paidAmount = quote.amount, - fees = total - meltResult.changeProofs.sumOf { it.amount } - quote.amount, + // Fee = proofs we spent − change we got back (both the pre-paid + // swap keep and the melt's own change) − the invoice amount. The + // pre-paid keep was split off before the melt and never burned, + // so it must not be counted as a fee. + fees = total - prePaidChangeAmount - meltResult.changeProofs.sumOf { it.amount } - quote.amount, historyEvent = historyEvent, deleteEvent = deleteEvent, newTokenEvent = finalChangeEvent, 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 08ad0f01f9..e21f4c46de 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 @@ -283,6 +283,23 @@ class CashuMintOperations( suspend fun meltQuoteStatus(quote: String): MeltQuoteBolt11ResponseDto = client.meltQuoteBolt11Status(quote) + /** + * NUT-02 input fee the active (output) keyset would charge to spend a + * proof set worth [amount] sats once it's split into power-of-two + * denominations. + * + * [CashuWalletOps.meltToLightning] swaps its inputs down onto the + * active keyset and the mint then charges this fee on top of + * `amount + fee_reserve`. Sizing selection + the swap-down with this + * fee included keeps the subsequent [meltProofs] from coming up short + * ("Inputs total X < required Y"). + */ + suspend fun activeKeysetInputFeeFor(amount: Long): Long { + if (amount <= 0L) return 0L + val keyset = fetchKeyset() + return computeInputFee(splitAmounts(amount).size, keyset.inputFeePpk) + } + /** * Pay the bolt11 invoice. Spends [inputs], which must total at least * `quote.amount + quote.fee_reserve`. Any change is returned blinded so the diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/NutTwoInputFeeTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/NutTwoInputFeeTest.kt index 98e1a38284..5e62c7ccea 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/NutTwoInputFeeTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/NutTwoInputFeeTest.kt @@ -149,4 +149,38 @@ class NutTwoInputFeeTest { fun `empty inputs cost nothing`() { assertEquals(0L, CashuMintOperations.computeInputFee(emptyList(), mapOf("k" to 100L))) } + + // --- Melt headroom: the fee the active keyset charges on the swapped-down + // send proofs, which meltToLightning must reserve on top of + // amount + fee_reserve. Mirrors CashuMintOperations.activeKeysetInputFeeFor, + // which is `computeInputFee(splitAmounts(amount).size, activePpk)`. --- + + private fun activeKeysetFeeFor( + amount: Long, + ppk: Long, + ) = CashuMintOperations.computeInputFee( + numInputs = CashuMintOperations.splitAmounts(amount).size, + inputFeePpk = ppk, + ) + + @Test + fun `melt headroom on a zero-fee active keyset is zero`() { + assertEquals(0L, activeKeysetFeeFor(amount = 83, ppk = 0L)) + } + + @Test + fun `melt headroom reserves the active keyset fee — coinos case`() { + // splitAmounts(83) = [1,2,16,64] → 4 proofs → ceil(4*100/1000) = 1. + // Without this 1-sat headroom, swapping down to exactly 83 leaves the + // melt one sat short of amount+fee_reserve+input_fee. + assertEquals(4, CashuMintOperations.splitAmounts(83).size) + assertEquals(1L, activeKeysetFeeFor(amount = 83, ppk = 100L)) + } + + @Test + fun `melt headroom grows with proof count on a fee keyset`() { + // splitAmounts(1023) = 10 set bits → 10 proofs → ceil(10*100/1000) = 1. + assertEquals(10, CashuMintOperations.splitAmounts(1023).size) + assertEquals(1L, activeKeysetFeeFor(amount = 1023, ppk = 100L)) + } }