Skip to content

test: melt with real routing fees on regtest - #1103

Open
orangeshyguy21 wants to merge 6 commits into
cashubtc:mainfrom
orangeshyguy21:chore/regtest-ln-fees
Open

test: melt with real routing fees on regtest#1103
orangeshyguy21 wants to merge 6 commits into
cashubtc:mainfrom
orangeshyguy21:chore/regtest-ln-fees

Conversation

@orangeshyguy21

Copy link
Copy Markdown
Contributor

Testing

Real routing fees

New melt tests pay invoices the mint can only reach through lnd-1, so every payment carries a real routing fee. Covered at both the mint API and wallet level; existing zero-fee tests are unchanged.

Sub-sat fee rounding

A 1000 sat melt costs 1001 msat. Asserts the mint rounds up to 2 sat and returns 18 sat change from the 20 sat reserve.

Fee accounting

Fee bounds are checked against the db fee_paid, and the change must equal the unspent reserve exactly.

Routed-invoice helper

get_real_invoice_routed picks the destination per backend (lnd-2 for LND mints, cln-1 for CLN) and waits for gossip to propagate before quoting. Replaces the unused get_unconnected_node_uri.


No changes to the regtest environment are needed. In the existing cashu-regtest-enviroment topology, every mint node already peers directly with lnd-1, and the routed destinations sit one hop behind it.

Note

The exact-fee assertions assume the environment's default lnd forwarding policy (1000 msat base + 1 ppm).

Add melt tests that pay invoices from nodes the mint has no direct channel to, so payments route through lnd-1 and incur a real routing fee. The routed destination differs per backend: lnd-2 for LND-backed mints, cln-1 for CLN-backed. The invoice helper waits for gossip to propagate first, since CLN nodes take up to a minute after boot to learn non-adjacent nodes. Existing zero-fee tests are unchanged.
Replace the callable-based gossip wait with a command-and-key helper, drop the multi-line docstrings and wrapped comments, and use the existing .copy()/.extend() idiom and SLEEP_TIME constant. Assertion messages now match the short Title-case style used elsewhere in the suite. No behavior change.
Add melt tests that pay invoices from nodes the mint has no direct
channel to, so payments route through lnd-1 and incur a real routing
fee. The routed destination differs per backend: lnd-2 for LND-backed
mints, cln-1 for CLN-backed. A 1000 sat melt costs 1001 msat, which
also covers the mint rounding sub-sat fees up to the next sat. The
invoice helper waits for gossip to propagate first, since CLN nodes
take up to a minute after boot to learn non-adjacent nodes. Existing
zero-fee tests are unchanged.
The wallet-level routed melt test now also checks the melt response state and payment preimage instead of only the balance, matching what the mint-level test asserts.
Assert fee bounds on the db-persisted fee_paid instead of a value derived
from the change, so the fee-reserve limit check can actually fail, and
verify the change compensates exactly for the unspent reserve. Drop a
tautological balance assert from the wallet-side test, which has no
independent fee signal to bound against.
@codecov

codecov Bot commented Aug 2, 2026

Copy link
Copy Markdown

❌ 1 Tests Failed:

Tests completed Failed Passed Skipped
826 1 825 85
View the top 3 failed test(s) by shortest run time
tests.mint.test_mint_api::test_melt_external_routing_fee_rounding
Stack Traces | 2.38s run time
ledger = <cashu.mint.ledger.Ledger object at 0x7f2f8c304fb0>
wallet = <cashu.wallet.wallet.Wallet object at 0x7f2f8cdc2990>

    @pytest.mark.asyncio
    @pytest.mark.skipif(
        settings.debug_mint_only_deprecated,
        reason="settings.debug_mint_only_deprecated is set",
    )
    @pytest.mark.skipif(
        is_fake,
        reason="only works on regtest",
    )
    async def test_melt_external_routing_fee_rounding(ledger: Ledger, wallet: Wallet):
        mint_quote = await wallet.request_mint(1024)
        await pay_if_regtest(mint_quote.request)
        await wallet.mint(1024, quote_id=mint_quote.quote)
        assert wallet.balance == 1024
    
        # external invoice that the mint can only pay through a routing node
        invoice_payment_request = get_real_invoice_routed(1000)
    
        quote = await wallet.melt_quote(invoice_payment_request)
        assert quote.amount == 1000
        # fee reserve is 2% of the amount
        assert quote.fee_reserve == 20
    
        keep, send = await wallet.swap_to_send(wallet.proofs, 1020)
        inputs_payload = [p.to_dict() for p in send]
    
        # 5 blank outputs for the change of the 20 sat fee reserve
        secrets, rs, derivation_paths = await wallet.generate_n_secrets(5)
        outputs, rs = wallet._construct_outputs([1, 1, 1, 1, 1], secrets, rs)
        outputs_payload = [o.model_dump() for o in outputs]
    
        response = httpx.post(
            f"{BASE_URL}.../v1/melt/bolt11",
            json={
                "quote": quote.quote,
                "inputs": inputs_payload,
                "outputs": outputs_payload,
            },
            timeout=None,
        )
>       response.raise_for_status()

tests/mint/test_mint_api.py:642: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <Response [400 Bad Request]>

    def raise_for_status(self) -> "Response":
        """
        Raise the `HTTPStatusError` if one occurred.
        """
        request = self._request
        if request is None:
            raise RuntimeError(
                "Cannot call `raise_for_status` as the request "
                "instance has not been set on this response."
            )
    
        if self.is_success:
            return self
    
        if self.has_redirect_location:
            message = (
                "{error_type} '{0.status_code} {0.reason_phrase}' for url '{0.url}'\n"
                "Redirect location: '{0.headers[location]}'\n"
                "For more information check: https://developer.mozilla..../Web/HTTP/Status/{0.status_code}"
            )
        else:
            message = (
                "{error_type} '{0.status_code} {0.reason_phrase}' for url '{0.url}'\n"
                "For more information check: https://developer.mozilla..../Web/HTTP/Status/{0.status_code}"
            )
    
        status_class = self.status_code // 100
        error_types = {
            1: "Informational response",
            3: "Redirect response",
            4: "Client error",
            5: "Server error",
        }
        error_type = error_types.get(status_class, "Invalid status code")
        message = message.format(self, error_type=error_type)
>       raise HTTPStatusError(message, request=request, response=self)
E       httpx.HTTPStatusError: Client error '400 Bad Request' for url 'http://localhost:3337.../v1/melt/bolt11'
E       For more information check: https://developer.mozilla..../Web/HTTP/Status/400

../../../..../pypoetry/virtualenvs/cashu-7hbqvzQf-py3.12/lib/python3.12.../site-packages/httpx/_models.py:758: HTTPStatusError
tests.mint.test_mint_api::test_melt_external_with_routing_fee
Stack Traces | 31.2s run time
ledger = <cashu.mint.ledger.Ledger object at 0x7f2f8f11dc70>
wallet = <cashu.wallet.wallet.Wallet object at 0x7f2f8f61a090>

    @pytest.mark.asyncio
    @pytest.mark.skipif(
        settings.debug_mint_only_deprecated,
        reason="settings.debug_mint_only_deprecated is set",
    )
    @pytest.mark.skipif(
        is_fake,
        reason="only works on regtest",
    )
    async def test_melt_external_with_routing_fee(ledger: Ledger, wallet: Wallet):
        mint_quote = await wallet.request_mint(64)
        await pay_if_regtest(mint_quote.request)
        await wallet.mint(64, quote_id=mint_quote.quote)
        assert wallet.balance == 64
    
        # external invoice that the mint can only pay through a routing node
        invoice_payment_request = get_real_invoice_routed(62)
    
        quote = await wallet.melt_quote(invoice_payment_request)
        assert quote.amount == 62
        assert quote.fee_reserve == 2
    
        keep, send = await wallet.swap_to_send(wallet.proofs, 64)
        inputs_payload = [p.to_dict() for p in send]
    
        # outputs for change
        secrets, rs, derivation_paths = await wallet.generate_n_secrets(1)
        outputs, rs = wallet._construct_outputs([2], secrets, rs)
        outputs_payload = [o.model_dump() for o in outputs]
    
        response = httpx.post(
            f"{BASE_URL}.../v1/melt/bolt11",
            json={
                "quote": quote.quote,
                "inputs": inputs_payload,
                "outputs": outputs_payload,
            },
            timeout=None,
        )
>       response.raise_for_status()

tests/mint/test_mint_api.py:585: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <Response [400 Bad Request]>

    def raise_for_status(self) -> "Response":
        """
        Raise the `HTTPStatusError` if one occurred.
        """
        request = self._request
        if request is None:
            raise RuntimeError(
                "Cannot call `raise_for_status` as the request "
                "instance has not been set on this response."
            )
    
        if self.is_success:
            return self
    
        if self.has_redirect_location:
            message = (
                "{error_type} '{0.status_code} {0.reason_phrase}' for url '{0.url}'\n"
                "Redirect location: '{0.headers[location]}'\n"
                "For more information check: https://developer.mozilla..../Web/HTTP/Status/{0.status_code}"
            )
        else:
            message = (
                "{error_type} '{0.status_code} {0.reason_phrase}' for url '{0.url}'\n"
                "For more information check: https://developer.mozilla..../Web/HTTP/Status/{0.status_code}"
            )
    
        status_class = self.status_code // 100
        error_types = {
            1: "Informational response",
            3: "Redirect response",
            4: "Client error",
            5: "Server error",
        }
        error_type = error_types.get(status_class, "Invalid status code")
        message = message.format(self, error_type=error_type)
>       raise HTTPStatusError(message, request=request, response=self)
E       httpx.HTTPStatusError: Client error '400 Bad Request' for url 'http://localhost:3337.../v1/melt/bolt11'
E       For more information check: https://developer.mozilla..../Web/HTTP/Status/400

../../../..../pypoetry/virtualenvs/cashu-7hbqvzQf-py3.12/lib/python3.12.../site-packages/httpx/_models.py:758: HTTPStatusError
tests.wallet.test_wallet::test_melt_routed_invoice
Stack Traces | 39.3s run time
self = <cashu.wallet.wallet.Wallet object at 0x7efee629a540>
proofs = [Proof(id='01d8a63077d0a51f9855f066409782ffcb322dc8a2265291865221ed06c039f6bc', amount=2, secret='85f926b51073cfde617a...path='HMAC-SHA256:01d8a63077d0a51f9855f066409782ffcb322dc8a2265291865221ed06c039f6bc:114', mint_id=None, melt_id=None)]
invoice = 'lnbcrt640n1p4xlxtnpp5p67zca4ugz5e0qmr5nuagmzyrfy4d4z7p99yvdttgmx64ws72d3qdqqcqzzsxqyz5vqsp59d25v065xra7u90nscka9qn4px...qxpqysgqy4jx9u9xvv76u0rrh232mts0mjpsvqq9qst56pt6a5en7s9sqcfjuefjylc3pufr00k009zkeehgnrrk0m4h7xs7f809hn9qglm59kqq4pxf2v'
fee_reserve_sat = 2, quote_id = '019fc3ef-6c3e-7b79-aa2c-c02b6758c1bf'
prefer_async = None

    async def melt(
        self,
        proofs: List[Proof],
        invoice: str,
        fee_reserve_sat: int,
        quote_id: str,
        prefer_async: Optional[bool] = None,
    ) -> PostMeltQuoteResponse:
        """Pays a lightning invoice and returns the status of the payment.
    
        Args:
            proofs (List[Proof]): List of proofs to be spent.
            invoice (str): Lightning invoice to be paid.
            fee_reserve_sat (int): Amount of fees to be reserved for the payment.
            prefer_async (Optional[bool]): Whether to pay asynchronously.
        """
    
        # Make sure we're operating on an independent copy of proofs
        proofs = copy.copy(proofs)
    
        # Generate a number of blank outputs for any overpaid fees. As described in
        # NUT-08, the mint will imprint these outputs with a value depending on the
        # amount of fees we overpaid.
        n_change_outputs = calculate_number_of_blank_outputs(fee_reserve_sat)
        (
            change_secrets,
            change_rs,
            change_derivation_paths,
        ) = await self.generate_n_secrets(n_change_outputs)
        change_outputs, change_rs = self._construct_outputs(
            n_change_outputs * [1], change_secrets, change_rs
        )
    
        await self.set_reserved_for_melt(proofs, reserved=True, quote_id=quote_id)
        proofs = self.sign_proofs_inplace_melt(proofs, change_outputs, quote_id)
        try:
>           melt_quote_resp = await super().melt(
                quote_id, proofs, change_outputs, prefer_async=prefer_async
            )

cashu/wallet/wallet.py:952: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
cashu/wallet/v1_api.py:91: in wrapper
    return await func(self, *args, **kwargs)
cashu/wallet/v1_api.py:104: in wrapper
    return await func(self, *args, **kwargs)
cashu/wallet/v1_api.py:559: in melt
    raise e
cashu/wallet/v1_api.py:548: in melt
    self.raise_on_error_request(resp)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

resp = <Response [400 Bad Request]>

    @staticmethod
    def raise_on_error_request(
        resp: Response,
    ) -> None:
        """Raises an exception if the response from the mint contains an error.
    
        Args:
            resp_dict (Response): Response dict (previously JSON) from mint
    
        Raises:
            Exception: if the response contains an error
        """
        try:
            resp_dict = resp.json()
        except json.JSONDecodeError:
            resp.raise_for_status()
            return
        if "detail" in resp_dict:
            logger.trace(f"Error from mint: {resp_dict}")
            error_message = f"Mint Error: {resp_dict['detail']}"
            if "code" in resp_dict:
                error_message += f" (Code: {resp_dict['code']})"
>           raise Exception(error_message)
E           Exception: Mint Error: Lightning payment failed: FAILURE_REASON_NO_ROUTE. (Code: 20004)

cashu/wallet/v1_api.py:145: Exception

During handling of the above exception, another exception occurred:

wallet1 = <cashu.wallet.wallet.Wallet object at 0x7efee629a540>

    @pytest.mark.asyncio
    @pytest.mark.skipif(is_fake, reason="only works on regtest")
    async def test_melt_routed_invoice(wallet1: Wallet):
        topup_mint_quote = await wallet1.request_mint(128)
        await pay_if_regtest(topup_mint_quote.request)
        await wallet1.mint(128, quote_id=topup_mint_quote.quote)
        assert wallet1.balance == 128
    
        # external invoice that the mint can only pay through a routing node
        invoice_payment_request = get_real_invoice_routed(64)
    
        quote = await wallet1.melt_quote(invoice_payment_request)
        total_amount = quote.amount + quote.fee_reserve
        assert quote.fee_reserve == 2
    
        _, send_proofs = await wallet1.swap_to_send(wallet1.proofs, total_amount)
    
>       melt_response = await wallet1.melt(
            proofs=send_proofs,
            invoice=invoice_payment_request,
            fee_reserve_sat=quote.fee_reserve,
            quote_id=quote.quote,
        )

tests/wallet/test_wallet.py:397: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <cashu.wallet.wallet.Wallet object at 0x7efee629a540>
proofs = [Proof(id='01d8a63077d0a51f9855f066409782ffcb322dc8a2265291865221ed06c039f6bc', amount=2, secret='85f926b51073cfde617a...path='HMAC-SHA256:01d8a63077d0a51f9855f066409782ffcb322dc8a2265291865221ed06c039f6bc:114', mint_id=None, melt_id=None)]
invoice = 'lnbcrt640n1p4xlxtnpp5p67zca4ugz5e0qmr5nuagmzyrfy4d4z7p99yvdttgmx64ws72d3qdqqcqzzsxqyz5vqsp59d25v065xra7u90nscka9qn4px...qxpqysgqy4jx9u9xvv76u0rrh232mts0mjpsvqq9qst56pt6a5en7s9sqcfjuefjylc3pufr00k009zkeehgnrrk0m4h7xs7f809hn9qglm59kqq4pxf2v'
fee_reserve_sat = 2, quote_id = '019fc3ef-6c3e-7b79-aa2c-c02b6758c1bf'
prefer_async = None

    async def melt(
        self,
        proofs: List[Proof],
        invoice: str,
        fee_reserve_sat: int,
        quote_id: str,
        prefer_async: Optional[bool] = None,
    ) -> PostMeltQuoteResponse:
        """Pays a lightning invoice and returns the status of the payment.
    
        Args:
            proofs (List[Proof]): List of proofs to be spent.
            invoice (str): Lightning invoice to be paid.
            fee_reserve_sat (int): Amount of fees to be reserved for the payment.
            prefer_async (Optional[bool]): Whether to pay asynchronously.
        """
    
        # Make sure we're operating on an independent copy of proofs
        proofs = copy.copy(proofs)
    
        # Generate a number of blank outputs for any overpaid fees. As described in
        # NUT-08, the mint will imprint these outputs with a value depending on the
        # amount of fees we overpaid.
        n_change_outputs = calculate_number_of_blank_outputs(fee_reserve_sat)
        (
            change_secrets,
            change_rs,
            change_derivation_paths,
        ) = await self.generate_n_secrets(n_change_outputs)
        change_outputs, change_rs = self._construct_outputs(
            n_change_outputs * [1], change_secrets, change_rs
        )
    
        await self.set_reserved_for_melt(proofs, reserved=True, quote_id=quote_id)
        proofs = self.sign_proofs_inplace_melt(proofs, change_outputs, quote_id)
        try:
            melt_quote_resp = await super().melt(
                quote_id, proofs, change_outputs, prefer_async=prefer_async
            )
        except Exception as e:
            logger.debug(f"Mint error: {e}")
            # remove the melt_id in proofs and set reserved to False
            await self.set_reserved_for_melt(proofs, reserved=False, quote_id=None)
>           raise Exception(f"could not pay invoice: {e}")
E           Exception: could not pay invoice: Mint Error: Lightning payment failed: FAILURE_REASON_NO_ROUTE. (Code: 20004)

cashu/wallet/wallet.py:959: Exception

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

Wait until the mint's node can compute a route (queryroutes/getroute)
instead of only seeing the destination's node announcement, which races
channel-policy gossip on fresh CI environments and caused NO_ROUTE
failures. Skip the exact fee-rounding test on CLN backends, whose
randomized pathfinding may pick a costlier route than the assertion
assumes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

1 participant