Rug the mints
A vulnerability that allows an attacker to steal money from cashu mints
This vulnerability does not exist in the main branch because lnbits is removed from nutshell. However, last release all version before that are affected.
Cashu mints quote a fee_reserve to the wallet at melt time, and the wallet escrows proofs worth amount + fee_reserve. The mint is then expected to bind that fee_reserve as an upper limit on what its lightning backend will spend.
Nutshell's LNbitsWallet.pay_invoice() ignores the fee_limit_msat argument and sends the bolt11 to the LNbits HTTP API with no cap field. Whatever fee LNbits pays up to its own LNBITS_RESERVE_FEE_PERCENT comes silently out of the mint operator's own funds that swallows negative-overpayment as a logger.error() and returns successfully.
Precondition
The attack fires only when the backend’s own reserve-fee cap is HIGHER than the mint’s quoted fee_reserve, that’s the window the attacker drains. The minibits-style config (mint quotes fee_reserve = 0, LNbits backend cap defaults to 1%) is the worst case.
An attacker who controls one routing channel on the path between the mint’s node and any reachable destination can force the mint’s actual fee to exceed the quoted fee_reserve and pocket the difference, round after round, until the operator notices their balance is bleeding.
Vulnerability
When a wallet asks the mint to pay a Lightning invoice, the mint computes a fee budget:
def fee_reserve(amount_msat: int) -> int:
return max(
int(settings.lightning_reserve_fee_min),
int(amount_msat * settings.lightning_fee_percent / 100.0),
)Some mints set LIGHTNING_FEE_PERCENT=0 and LIGHTNING_RESERVE_FEE_MIN=0 or use non-defaullts. I found 24 mints with input_fee_ppk: 0.
The mint then sends the quote to the wallet and the wallet’s proofs lock the amount and the quoted fee_reserve. The contract implied by NUT-05 and enforced by every wallet is the mint will not spend more than amount + fee_reserve from its backend on this payment.
async def pay_invoice(
self, quote: MeltQuote, fee_limit_msat: int
) -> PaymentResponse:
try:
r = await self.client.post(
url=f"{self.endpoint}/api/v1/payments",
json={"out": True, "bolt11": quote.request},
timeout=None,
)The fee_limit_msat parameter is accepted by the function signature, then thrown away. The LNbits /api/v1/payments endpoint accepts a fee_limit_msat field (the LNbits internal payment_with_max_fee() plumbing introduced by PR #447 in 2021).
When the backend reports back, the mint compares fee_provided (what the wallet paid into the quote) against fee_paid (what the backend actually spent):
overpaid_fee = fee_provided - fee_paid
if overpaid_fee <= 0 or outputs is None:
if overpaid_fee < 0:
logger.error(
f"Overpaid fee is negative ({overpaid_fee}). This should not happen."
)
return []If overpaid_fee is negative i.e. the backend spent more than the wallet escrowed, the mint logs an error and returns an empty list of change. It does not unwind, it does not raise, it does not refuse the spend. The melt is recorded as successful. The proofs are marked spent. The mint operator’s lightning balance is short by exactly fee_paid − fee_provided with no entry in the ledger to recover it.
It fires every time an attacker manipulates routing fees to exceed the quoted reserve.
Attack
The attack is exploitable against a mint if all these hold:
Driver drops the fee cap: Mint’s *.lightning.<driver>.pay_invoice() does not pass fee_limit_msat through to its backend’s API. This is confirmed for LNbitsWallet in Nutshell and cdk-lnbits in CDK.
Mint’s quoted fee_reserve is lower than the backend’s own reserve-fee cap: The backend’s cap is the only thing that ultimately bounds the attacker’s draw, so the exploitable amount per melt is backend_cap − mint_quoted_fee_reserve. If(LIGHTNING_FEE_PERCENT=0, LIGHTNING_RESERVE_FEE_MIN=0, LNbits default LNBITS_RESERVE_FEE_PERCENT=1.0) gives an attacker the entire 1% gap. If the mint operator raises their fee_reserve to at or above the backend cap, the attack window collapses to zero.
Attacker controls one channel on a route the mint will use: Either by running a channel directly to the mint’s outbound peer with a high outbound fee_rate_ppm or by being the destination node and forcing routing through their high-fee peer.
Steps to reproduce
Attacker requests an
AMOUNT-sat mint quote, pays the invoice. Mint receivesAMOUNT − routing_feenet (routing fee captured by the attacker’s channel if it’s on the path).Attacker sets a high
fee_rate_ppm(e.g. 10000 = 1%) on their channel.Attacker requests a melt quote for an invoice they generated to themselves at the same
AMOUNT. Mint returnsfee_reserve = 0. Attacker submits the melt with proofs worth exactlyAMOUNT.The route includes the attacker’s high-fee channel. Mint pays
AMOUNT + 1% routing feefrom LNbits. LNbits driver drops the cap; LND finds the high-fee route; payment succeeds.fee_provided = 0,fee_paid = AMOUNT × 0.01.overpaid_fee = −AMOUNT × 0.01. Silent error log. Melt returns success. Attacker has cycledAMOUNTof ecash back to themselves AND extractedAMOUNT × 0.01from the mint’s hot wallet via the routing fee.
Proof of concept
I have tried this attack on regtest with bitcoind, lnbits, nutshell, lnd etc. ├── attack.py├── docker-compose.yml├── mint.env.example├── README.md├── setup.sh└── teardown.sh
Repository: https://github.com/1440000bytes/rugme
This test passes for cashubtc/nutshell version 0.20.1 and demonstrates the silent-absorb branch without any Lightning network. It uses a FakeWallet backend in which we configure the backend's actual fee to exceed the quoted reserve.
import pytest
from cashu.core.base import Method, Unit
from cashu.core.settings import settings
from cashu.mint.ledger import Ledger
from cashu.wallet.wallet import Wallet
from tests.conftest import SERVER_ENDPOINT
@pytest.mark.asyncio
async def test_fee_limit_bypass_single_melt(ledger: Ledger):
settings.lightning_fee_percent = 0.0
settings.lightning_reserve_fee_min = 0
settings.fakewallet_brr = True
settings.fakewallet_payment_state = "SETTLED"
settings.fakewallet_pay_invoice_state = "SETTLED"
settings.fakewallet_delay_outgoing_payment = 0
settings.fakewallet_delay_incoming_payment = 0
wallet = await Wallet.with_db(
url=SERVER_ENDPOINT,
db="data/rugme_single",
name="rugme_single",
)
await wallet.load_mint()
invoice = await wallet.request_mint(1000)
await ledger.get_mint_quote(invoice.quote)
await wallet.mint(1000, invoice.quote)
assert wallet.available_balance == 1000
melt_invoice = await wallet.request_mint(1000)
melt_q = await wallet.melt_quote(melt_invoice.request)
assert melt_q.fee_reserve == 0, "Precondition: mint quotes 0 fee"
ledger.backends[Method.bolt11][Unit.sat].pay_invoice_fee_msat = 50_000
proofs_to_send, _ = await wallet.select_to_send(wallet.proofs, 1000)
melt_resp = await wallet.melt(
proofs_to_send, melt_invoice.request, 0, melt_q.quote
)
assert melt_resp.state == "PAID" Fix
Pass fee_limit_msat to the backend
async def pay_invoice(
self, quote: MeltQuote, fee_limit_msat: int
) -> PaymentResponse:
try:
r = await self.client.post(
url=f"{self.endpoint}/api/v1/payments",
- json={"out": True, "bolt11": quote.request},
+ json={
+ "out": True,
+ "bolt11": quote.request,
+ "fee_limit_msat": fee_limit_msat,
+ },
timeout=None,
)Make the ledger fail loud on negative overpayment
-if overpaid_fee <= 0 or outputs is None:
- if overpaid_fee < 0:
- logger.error(
- f"Overpaid fee is negative ({overpaid_fee}). This should not happen."
- )
- return []
+if overpaid_fee < 0:
+ logger.error(
+ f"Backend paid more than the quoted fee_reserve (overpaid_fee={overpaid_fee}). "
+ f"Rejecting melt to prevent silent operator loss."
+ )
+ raise TransactionError(
+ f"backend overpaid fee_reserve by {-overpaid_fee} msat; "
+ f"mint rejecting melt"
+ )
+if overpaid_fee == 0 or outputs is None:
+ return []Mint operators
Close the gap between the mint’s quoted fee_reserve and the backend’s own cap. Concretely, for operators currently running with LIGHTNING_FEE_PERCENT=0 and LIGHTNING_RESERVE_FEE_MIN=0:
Raise
LIGHTNING_FEE_PERCENTto at or above the backend’s own cap (for LNbits, that meansLIGHTNING_FEE_PERCENT >= LNBITS_RESERVE_FEE_PERCENT, i.e. at least 1.0 with default LNbits). This forces the wallet to escrow at least as much as the backend can ever pay, sooverpaid_feecannot go negative. Wallets may pay slightly more for melts; legitimate users get the excess returned as NUT-08 change.Set
LIGHTNING_RESERVE_FEE_MINto at least 2000 msat so small melts still have a floor (1% of a 100-sat melt is 1 sat = below msat precision).For LNbits backends, also lowering
LNBITS_RESERVE_FEE_PERCENTreduces the amount per melt the attacker can drain, but does not close the bug and only raising the mint quote does.Monitor
mint.logforOverpaid fee is negativelines. Any occurrence is a fund loss event and indicates active exploitation or an unhealthy backend.
Note: This bug also affects CDK but I have not reviewed that in detail or tested it.



