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)) + } }