fix(cashu): reserve the melt's NUT-02 input fee in send-LN swap-down

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Ffjz3doZ5CtFtAWSpvmqR
This commit is contained in:
Claude
2026-06-17 21:28:50 +00:00
parent 32a30453f3
commit d9a9ecdc05
3 changed files with 70 additions and 8 deletions
@@ -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,
@@ -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
@@ -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))
}
}