Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 50 additions & 6 deletions tests/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ async def get_random_invoice_data():
WALLET = wallet_class(unit=Unit.sat)
is_fake: bool = WALLET.__class__.__name__ == "FakeWallet"
is_regtest: bool = not is_fake
is_cln_backend: bool = WALLET.__class__.__name__ in [
"CLNRestWallet",
"CoreLightningRestWallet",
]
is_deprecated_api_only = settings.debug_mint_only_deprecated
is_github_actions = os.getenv("GITHUB_ACTIONS") == "true"
is_postgres = settings.mint_database.startswith("postgres")
Expand All @@ -81,7 +85,7 @@ async def get_random_invoice_data():
"--rpcserver=lnd-1",
]

docker_lightning_unconnected_cli = [
docker_lightning_routed_cli = [
"docker",
"exec",
"cashu-lnd-2-1",
Expand All @@ -92,6 +96,17 @@ async def get_random_invoice_data():
]


docker_lightning_mint_cli = [
"docker",
"exec",
"cashu-lnd-3-1",
"lncli",
"--network",
"regtest",
"--rpcserver=lnd-3",
]


def docker_clightning_cli(index):
return [
"docker",
Expand Down Expand Up @@ -188,12 +203,41 @@ def get_real_invoice_cln(sats: int) -> str:
return result["bolt11"]


def get_unconnected_node_uri() -> str:
cmd = docker_lightning_unconnected_cli.copy()
def _wait_for_route(cmd: list, key: str, timeout: int = 60) -> None:
"""Wait until the route query in `cmd` returns a result under `key`."""
start = time.time()
while time.time() - start < timeout:
if run_cmd_json(cmd).get(key):
return
time.sleep(SLEEP_TIME)
raise TimeoutError(f"no route found after {timeout} seconds")


def get_real_invoice_routed(sats: int) -> str:
"""Get an invoice from a node the mint has no direct channel to."""
# the mint pays a routed invoice through lnd-1, which charges a routing fee
if is_cln_backend:
# the mint is clightning-2, whose direct peers are lnd-1 and lnd-2 – so we use cln-1
cmd = docker_clightning_cli(1)
cmd.append("getinfo")
destination = run_cmd_json(cmd)["id"]
cmd = docker_clightning_cli(2)
cmd.extend(["getroute", destination, str(sats * 1000), "10"])
# channel policies take a while to gossip after the regtest environment started
_wait_for_route(cmd, "route")
return get_real_invoice_cln(sats)

# the mint is lnd-3, whose direct peers are lnd-1, cln-1 and cln-3 – so we use lnd-2
cmd = docker_lightning_routed_cli.copy()
cmd.append("getinfo")
info = run_cmd_json(cmd)
pubkey = info["identity_pubkey"]
return f"{pubkey}@lnd-2:9735"
destination = run_cmd_json(cmd)["identity_pubkey"]
cmd = docker_lightning_mint_cli.copy()
cmd.extend(["queryroutes", "--dest", destination, "--amt", str(sats)])
# channel policies take a while to gossip after the regtest environment started
_wait_for_route(cmd, "routes")
cmd = docker_lightning_routed_cli.copy()
cmd.extend(["addinvoice", str(sats)])
return run_cmd_json(cmd)["payment_request"]


async def pay_if_regtest(bolt11: str) -> None:
Expand Down
125 changes: 124 additions & 1 deletion tests/mint/test_mint_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,14 @@
from cashu.mint.ledger import Ledger
from cashu.wallet.crud import bump_secret_derivation
from cashu.wallet.wallet import Wallet
from tests.helpers import get_real_invoice, is_fake, is_regtest, pay_if_regtest
from tests.helpers import (
get_real_invoice,
get_real_invoice_routed,
is_cln_backend,
is_fake,
is_regtest,
pay_if_regtest,
)

BASE_URL = "http://localhost:3337"

Expand Down Expand Up @@ -537,6 +544,122 @@ async def test_melt_external(ledger: Ledger, wallet: Wallet):
assert resp_quote.state == MeltQuoteState.paid.value


@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()
assert response.status_code == 200, f"{response.url} {response.status_code}"
resp_quote = PostMeltQuoteResponse(**response.json())
assert resp_quote.state == MeltQuoteState.paid.value
assert resp_quote.payment_preimage is not None

melt_quote = await ledger.crud.get_melt_quote(quote_id=quote.quote, db=ledger.db)
assert melt_quote, "No melt quote in db"
assert melt_quote.fee_paid > 0, "No routing fee paid"
# the mint passes the fee reserve to the backend as the fee limit
assert melt_quote.fee_paid <= quote.fee_reserve, "Fee exceeded the fee reserve"

# change must compensate exactly for the unspent part of the reserve
change_sat = sum([c.amount for c in resp_quote.change or []])
assert change_sat == quote.fee_reserve - melt_quote.fee_paid, "Wrong change returned"


@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",
)
@pytest.mark.skipif(
is_cln_backend,
reason="CLN pathfinding is randomized, the exact fee is not deterministic",
)
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()
assert response.status_code == 200, f"{response.url} {response.status_code}"
resp_quote = PostMeltQuoteResponse(**response.json())
assert resp_quote.state == MeltQuoteState.paid.value

# the routing fee for 1000 sat is 1001 msat (1000 msat base fee + 1 ppm)
# which the mint must round up to 2 sat when it accounts the fee
melt_quote = await ledger.crud.get_melt_quote(quote_id=quote.quote, db=ledger.db)
assert melt_quote, "No melt quote in db"
assert melt_quote.fee_paid == 2, "Fee not rounded up to the next sat"

# we get back the fee reserve minus the rounded up fee
change_sat = sum([c.amount for c in resp_quote.change or []])
assert change_sat == 18, "Wrong change returned"


@pytest.mark.asyncio
@pytest.mark.skipif(
settings.debug_mint_only_deprecated,
Expand Down
31 changes: 31 additions & 0 deletions tests/wallet/test_wallet.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from tests.conftest import SERVER_ENDPOINT
from tests.helpers import (
get_real_invoice,
get_real_invoice_routed,
is_deprecated_api_only,
is_fake,
is_github_actions,
Expand Down Expand Up @@ -376,6 +377,36 @@ async def test_melt(wallet1: Wallet):
assert wallet1.balance == 64, "Wrong balance"


@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,
)
assert melt_response.state == MeltQuoteState.paid.value, "Melt not paid"
assert melt_response.payment_preimage is not None, "No payment preimage"

# the payment had a routing fee, so we get back less than the full fee reserve
assert wallet1.balance < 128 - quote.amount, "No routing fee paid"


@pytest.mark.asyncio
@pytest.mark.skipif(is_deprecated_api_only, reason="Deprecated API only")
async def test_get_melt_quote_state(wallet1: Wallet):
Expand Down
Loading