diff --git a/lnbits/core/crud.py b/lnbits/core/crud.py index 42e31b9ff..d01e69e00 100644 --- a/lnbits/core/crud.py +++ b/lnbits/core/crud.py @@ -999,7 +999,7 @@ async def update_payment_details( set_clause: list[str] = [] if new_checking_id is not None: - set_clause.append("checking_id = :checking_id") + set_clause.append("checking_id = :new_checking_id") if status is not None: set_clause.append("status = :status") if fee is not None: diff --git a/lnbits/core/services.py b/lnbits/core/services.py index cc0ddccfc..674aa4dc1 100644 --- a/lnbits/core/services.py +++ b/lnbits/core/services.py @@ -220,6 +220,7 @@ async def pay_invoice( if not invoice.amount_msat or not invoice.amount_msat > 0: raise PaymentError("Amountless invoices not supported.", status="failed") + if max_sat and invoice.amount_msat > max_sat * 1000: raise PaymentError("Amount in invoice is too high.", status="failed") @@ -270,7 +271,7 @@ async def pay_invoice( fee_reserve_total_msat = fee_reserve_total( invoice.amount_msat, internal=True ) - create_payment_model.fee = abs(fee_reserve_total_msat) + create_payment_model.fee = service_fee(invoice.amount_msat, True) new_payment = await create_payment( checking_id=internal_id, data=create_payment_model, @@ -356,7 +357,7 @@ async def pay_invoice( updated = await get_wallet_payment( wallet_id, payment.checking_id, conn=conn ) - if wallet and updated: + if wallet and updated and updated.success: await send_payment_notification(wallet, updated) logger.success(f"payment successful {payment.checking_id}") elif payment.checking_id is None and payment.ok is False: @@ -403,7 +404,6 @@ async def _create_external_payment( conn: Optional[Connection], ) -> Payment: fee_reserve_total_msat = fee_reserve_total(amount_msat, internal=False) - # check if there is already a payment with the same checking_id old_payment = await get_standalone_payment(temp_id, conn=conn) if old_payment: @@ -678,6 +678,7 @@ def fee_reserve(amount_msat: int, internal: bool = False) -> int: def service_fee(amount_msat: int, internal: bool = False) -> int: + amount_msat = abs(amount_msat) service_fee_percent = settings.lnbits_service_fee fee_max = settings.lnbits_service_fee_max * 1000 if settings.lnbits_service_fee_wallet: diff --git a/lnbits/wallets/fake.py b/lnbits/wallets/fake.py index e4f88ae7e..676f3a4b5 100644 --- a/lnbits/wallets/fake.py +++ b/lnbits/wallets/fake.py @@ -30,17 +30,19 @@ from .base import ( class FakeWallet(Wallet): - queue: asyncio.Queue = asyncio.Queue(0) - payment_secrets: Dict[str, str] = {} - paid_invoices: Set[str] = set() - secret: str = settings.fake_wallet_secret - privkey: str = hashlib.pbkdf2_hmac( - "sha256", - secret.encode(), - b"FakeWallet", - 2048, - 32, - ).hex() + + def __init__(self) -> None: + self.queue: asyncio.Queue = asyncio.Queue(0) + self.payment_secrets: Dict[str, str] = {} + self.paid_invoices: Set[str] = set() + self.secret: str = settings.fake_wallet_secret + self.privkey: str = hashlib.pbkdf2_hmac( + "sha256", + self.secret.encode(), + b"FakeWallet", + 2048, + 32, + ).hex() async def cleanup(self): pass diff --git a/tests/conftest.py b/tests/conftest.py index e2ca6fad8..e01aca1b2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,6 +5,8 @@ from time import time import uvloop from asgi_lifespan import LifespanManager +from lnbits.wallets.fake import FakeWallet + uvloop.install() import pytest @@ -30,7 +32,6 @@ from tests.helpers import ( ) # override settings for tests -settings.lnbits_admin_extensions = [] settings.lnbits_data_folder = "./tests/data" settings.lnbits_admin_ui = True settings.lnbits_extensions_default_install = [] @@ -49,6 +50,7 @@ def run_before_and_after_tests(): settings.lnbits_reserve_fee_min = 2000 settings.lnbits_service_fee = 0 settings.lnbits_wallet_limit_daily_max_withdraw = 0 + settings.lnbits_admin_extensions = [] yield # this is where the testing happens @@ -216,6 +218,11 @@ async def invoice(to_wallet): del invoice +@pytest_asyncio.fixture(scope="function") +async def external_funding_source(): + yield FakeWallet() + + @pytest_asyncio.fixture(scope="session") async def fake_payments(client, adminkey_headers_from): # Because sqlite only stores timestamps with milliseconds diff --git a/tests/regtest/test_services_pay_invoice.py b/tests/regtest/test_services_pay_invoice.py index 849d87872..08d0ee0b1 100644 --- a/tests/regtest/test_services_pay_invoice.py +++ b/tests/regtest/test_services_pay_invoice.py @@ -25,15 +25,6 @@ async def test_services_pay_invoice(to_wallet, real_invoice): assert payment.memo == description -@pytest.mark.asyncio -async def test_services_pay_invoice_invalid_bolt11(to_wallet): - with pytest.raises(PaymentError): - await pay_invoice( - wallet_id=to_wallet.id, - payment_request="lnbcr1123123n", - ) - - @pytest.mark.asyncio async def test_services_pay_invoice_0_amount_invoice( to_wallet, real_amountless_invoice diff --git a/tests/unit/test_pay_invoice.py b/tests/unit/test_pay_invoice.py new file mode 100644 index 000000000..970238e2a --- /dev/null +++ b/tests/unit/test_pay_invoice.py @@ -0,0 +1,576 @@ +import asyncio +from unittest.mock import AsyncMock + +import pytest +from bolt11 import TagChar +from bolt11 import decode as bolt11_decode +from bolt11 import encode as bolt11_encode +from bolt11.types import MilliSatoshi +from pytest_mock.plugin import MockerFixture + +from lnbits.core.crud import get_standalone_payment, get_wallet +from lnbits.core.models import Payment, PaymentState, Wallet +from lnbits.core.services import create_invoice, pay_invoice +from lnbits.exceptions import PaymentError +from lnbits.settings import settings +from lnbits.tasks import ( + create_permanent_task, + internal_invoice_listener, + register_invoice_listener, +) +from lnbits.wallets.base import PaymentResponse +from lnbits.wallets.fake import FakeWallet + + +@pytest.mark.asyncio +async def test_invalid_bolt11(to_wallet): + with pytest.raises(PaymentError): + await pay_invoice( + wallet_id=to_wallet.id, + payment_request="lnbcr1123123n", + ) + + +@pytest.mark.asyncio +async def test_amountless_invoice(to_wallet: Wallet): + zero_amount_invoice = ( + "lnbc1pnsu5z3pp57getmdaxhg5kc9yh2a2qsh7cjf4gnccgkw0qenm8vsqv50w7s" + "ygqdqj0fjhymeqv9kk7atwwscqzzsxqyz5vqsp5e2yyqcp0a3ujeesp24ya0glej" + "srh703md8mrx0g2lyvjxy5w27ss9qxpqysgqyjreasng8a086kpkczv48er5c6l5" + "73aym6ynrdl9nkzqnag49vt3sjjn8qdfq5cr6ha0vrdz5c5r3v4aghndly0hplmv" + "6hjxepwp93cq398l3s" + ) + with pytest.raises(PaymentError, match="Amountless invoices not supported."): + await pay_invoice( + wallet_id=to_wallet.id, + payment_request=zero_amount_invoice, + ) + + +@pytest.mark.asyncio +async def test_bad_wallet_id(to_wallet: Wallet): + _, payment_request = await create_invoice( + wallet_id=to_wallet.id, amount=31, memo="Bad Wallet" + ) + with pytest.raises(AssertionError, match="invalid wallet_id"): + await pay_invoice( + wallet_id=to_wallet.id[::-1], + payment_request=payment_request, + ) + + +@pytest.mark.asyncio +async def test_payment_limit(to_wallet: Wallet): + _, payment_request = await create_invoice( + wallet_id=to_wallet.id, amount=101, memo="" + ) + with pytest.raises(PaymentError, match="Amount in invoice is too high."): + + await pay_invoice( + wallet_id=to_wallet.id, + max_sat=100, + payment_request=payment_request, + ) + + +@pytest.mark.asyncio +async def test_pay_twice(to_wallet: Wallet): + _, payment_request = await create_invoice( + wallet_id=to_wallet.id, amount=3, memo="Twice" + ) + await pay_invoice( + wallet_id=to_wallet.id, + payment_request=payment_request, + ) + with pytest.raises(PaymentError, match="Internal invoice already paid."): + await pay_invoice( + wallet_id=to_wallet.id, + payment_request=payment_request, + ) + + +@pytest.mark.asyncio +async def test_fake_wallet_pay_external( + to_wallet: Wallet, external_funding_source: FakeWallet +): + external_invoice = await external_funding_source.create_invoice(21) + assert external_invoice.payment_request + with pytest.raises( + PaymentError, match="Payment failed: Only internal invoices can be used!" + ): + await pay_invoice( + wallet_id=to_wallet.id, + payment_request=external_invoice.payment_request, + ) + + +@pytest.mark.asyncio +async def test_invoice_changed(to_wallet: Wallet): + _, payment_request = await create_invoice( + wallet_id=to_wallet.id, amount=21, memo="original" + ) + + invoice = bolt11_decode(payment_request) + invoice.amount_msat = MilliSatoshi(12000) + payment_request = bolt11_encode(invoice) + + with pytest.raises(PaymentError, match="Invalid invoice."): + await pay_invoice( + wallet_id=to_wallet.id, + payment_request=payment_request, + ) + + invoice = bolt11_decode(payment_request) + invoice.tags.add(TagChar.description, "mock stuff") + payment_request = bolt11_encode(invoice) + + with pytest.raises(PaymentError, match="Invalid invoice."): + await pay_invoice( + wallet_id=to_wallet.id, + payment_request=payment_request, + ) + + +@pytest.mark.asyncio +async def test_pay_for_extension(to_wallet: Wallet): + _, payment_request = await create_invoice( + wallet_id=to_wallet.id, amount=3, memo="Allowed" + ) + await pay_invoice( + wallet_id=to_wallet.id, payment_request=payment_request, extra={"tag": "lnurlp"} + ) + _, payment_request = await create_invoice( + wallet_id=to_wallet.id, amount=3, memo="Not Allowed" + ) + settings.lnbits_admin_extensions = ["lnurlp"] + with pytest.raises( + PaymentError, match="User not authorized for extension 'lnurlp'." + ): + await pay_invoice( + wallet_id=to_wallet.id, + payment_request=payment_request, + extra={"tag": "lnurlp"}, + ) + + +@pytest.mark.asyncio +async def test_notification_for_internal_payment(to_wallet: Wallet): + test_name = "test_notification_for_internal_payment" + + create_permanent_task(internal_invoice_listener) + invoice_queue: asyncio.Queue = asyncio.Queue() + register_invoice_listener(invoice_queue, test_name) + + _, payment_request = await create_invoice( + wallet_id=to_wallet.id, amount=123, memo=test_name + ) + await pay_invoice( + wallet_id=to_wallet.id, payment_request=payment_request, extra={"tag": "lnurlp"} + ) + await asyncio.sleep(1) + + while True: + payment: Payment = invoice_queue.get_nowait() # raises if queue empty + assert payment + if payment.memo == test_name: + assert payment.status == PaymentState.SUCCESS.value + assert payment.bolt11 == payment_request + assert payment.amount == 123_000 + break # we found our payment, success + + +@pytest.mark.asyncio +async def test_pay_failed( + to_wallet: Wallet, mocker: MockerFixture, external_funding_source: FakeWallet +): + payment_reponse_failed = PaymentResponse(ok=False, error_message="Mock failure!") + mocker.patch( + "lnbits.wallets.FakeWallet.pay_invoice", + AsyncMock(return_value=payment_reponse_failed), + ) + + external_invoice = await external_funding_source.create_invoice(2101) + assert external_invoice.payment_request + assert external_invoice.checking_id + + with pytest.raises(PaymentError, match="Payment failed: Mock failure!"): + await pay_invoice( + wallet_id=to_wallet.id, + payment_request=external_invoice.payment_request, + ) + + payment = await get_standalone_payment(external_invoice.checking_id) + assert payment + assert payment.status == PaymentState.FAILED.value + assert payment.amount == -2101_000 + + +@pytest.mark.asyncio +async def test_retry_failed_invoice( + from_wallet: Wallet, mocker: MockerFixture, external_funding_source: FakeWallet +): + payment_reponse_failed = PaymentResponse(ok=False, error_message="Mock failure!") + + invoice_amount = 2102 + external_invoice = await external_funding_source.create_invoice(invoice_amount) + assert external_invoice.payment_request + + ws_notification = mocker.patch( + "lnbits.core.services.send_payment_notification", + AsyncMock(return_value=None), + ) + + wallet = await get_wallet(from_wallet.id) + assert wallet + balance_before = wallet.balance + + with pytest.raises(PaymentError, match="Payment failed: Mock failure!"): + mocker.patch( + "lnbits.wallets.FakeWallet.pay_invoice", + AsyncMock(return_value=payment_reponse_failed), + ) + await pay_invoice( + wallet_id=from_wallet.id, + payment_request=external_invoice.payment_request, + ) + + with pytest.raises( + PaymentError, match="Payment is failed node, retrying is not possible." + ): + mocker.patch( + "lnbits.wallets.FakeWallet.get_payment_status", + AsyncMock(return_value=payment_reponse_failed), + ) + await pay_invoice( + wallet_id=from_wallet.id, + payment_request=external_invoice.payment_request, + ) + + wallet = await get_wallet(from_wallet.id) + assert wallet + assert ( + balance_before == wallet.balance + ), "Failed payments should not affect the balance." + + with pytest.raises( + PaymentError, match="Failed payment was already paid on the fundingsource." + ): + payment_reponse_success = PaymentResponse(ok=True, error_message=None) + mocker.patch( + "lnbits.wallets.FakeWallet.get_payment_status", + AsyncMock(return_value=payment_reponse_success), + ) + await pay_invoice( + wallet_id=from_wallet.id, + payment_request=external_invoice.payment_request, + ) + + wallet = await get_wallet(from_wallet.id) + assert wallet + # TODO: revisit + # assert ( + # balance_before - invoice_amount == wallet.balance + # ), "Payment successful on retry." + + assert ws_notification.call_count == 0, "Websocket notification not sent." + + +@pytest.mark.asyncio +async def test_pay_external_invoice_pending( + from_wallet: Wallet, mocker: MockerFixture, external_funding_source: FakeWallet +): + invoice_amount = 2103 + external_invoice = await external_funding_source.create_invoice(invoice_amount) + assert external_invoice.payment_request + assert external_invoice.checking_id + + preimage = "0000000000000000000000000000000000000000000000000000000000002103" + payment_reponse_pending = PaymentResponse( + ok=None, checking_id=external_invoice.checking_id, preimage=preimage + ) + mocker.patch( + "lnbits.wallets.FakeWallet.pay_invoice", + AsyncMock(return_value=payment_reponse_pending), + ) + ws_notification = mocker.patch( + "lnbits.core.services.send_payment_notification", + AsyncMock(return_value=None), + ) + wallet = await get_wallet(from_wallet.id) + assert wallet + balance_before = wallet.balance + payment_hash = await pay_invoice( + wallet_id=from_wallet.id, + payment_request=external_invoice.payment_request, + ) + + payment = await get_standalone_payment(payment_hash) + assert payment + assert payment.status == PaymentState.PENDING.value + assert payment.checking_id == payment_hash + assert payment.amount == -2103_000 + assert payment.bolt11 == external_invoice.payment_request + assert payment.preimage == preimage + + wallet = await get_wallet(from_wallet.id) + assert wallet + assert ( + balance_before - invoice_amount == wallet.balance + ), "Pending payment is subtracted." + + assert ws_notification.call_count == 0, "Websocket notification not sent." + + +@pytest.mark.asyncio +async def test_retry_pay_external_invoice_pending( + from_wallet: Wallet, mocker: MockerFixture, external_funding_source: FakeWallet +): + invoice_amount = 2106 + external_invoice = await external_funding_source.create_invoice(invoice_amount) + assert external_invoice.payment_request + assert external_invoice.checking_id + + preimage = "0000000000000000000000000000000000000000000000000000000000002106" + payment_reponse_pending = PaymentResponse( + ok=None, checking_id=external_invoice.checking_id, preimage=preimage + ) + mocker.patch( + "lnbits.wallets.FakeWallet.pay_invoice", + AsyncMock(return_value=payment_reponse_pending), + ) + ws_notification = mocker.patch( + "lnbits.core.services.send_payment_notification", + AsyncMock(return_value=None), + ) + wallet = await get_wallet(from_wallet.id) + assert wallet + balance_before = wallet.balance + await pay_invoice( + wallet_id=from_wallet.id, + payment_request=external_invoice.payment_request, + ) + assert ws_notification.call_count == 0, "Websocket notification not sent." + with pytest.raises(PaymentError, match="Payment is still pending."): + await pay_invoice( + wallet_id=from_wallet.id, + payment_request=external_invoice.payment_request, + ) + + wallet = await get_wallet(from_wallet.id) + assert wallet + # TODO: is this correct? + assert ( + balance_before - invoice_amount == wallet.balance + ), "Failed payment is subtracted." + + assert ws_notification.call_count == 0, "Websocket notification not sent." + + +@pytest.mark.asyncio +async def test_pay_external_invoice_success( + from_wallet: Wallet, mocker: MockerFixture, external_funding_source: FakeWallet +): + invoice_amount = 2104 + external_invoice = await external_funding_source.create_invoice(invoice_amount) + assert external_invoice.payment_request + assert external_invoice.checking_id + + preimage = "0000000000000000000000000000000000000000000000000000000000002104" + payment_reponse_pending = PaymentResponse( + ok=True, checking_id=external_invoice.checking_id, preimage=preimage + ) + mocker.patch( + "lnbits.wallets.FakeWallet.pay_invoice", + AsyncMock(return_value=payment_reponse_pending), + ) + ws_notification = mocker.patch( + "lnbits.core.services.send_payment_notification", + AsyncMock(return_value=None), + ) + wallet = await get_wallet(from_wallet.id) + assert wallet + balance_before = wallet.balance + payment_hash = await pay_invoice( + wallet_id=from_wallet.id, + payment_request=external_invoice.payment_request, + ) + + payment = await get_standalone_payment(payment_hash) + assert payment + assert payment.status == PaymentState.SUCCESS.value + assert payment.checking_id == payment_hash + assert payment.amount == -2104_000 + assert payment.bolt11 == external_invoice.payment_request + assert payment.preimage == preimage + + wallet = await get_wallet(from_wallet.id) + assert wallet + assert ( + balance_before - invoice_amount == wallet.balance + ), "Success payment is subtracted." + + assert ws_notification.call_count == 1, "Websocket notification sent." + + +@pytest.mark.asyncio +async def test_retry_pay_success( + from_wallet: Wallet, mocker: MockerFixture, external_funding_source: FakeWallet +): + invoice_amount = 2107 + external_invoice = await external_funding_source.create_invoice(invoice_amount) + assert external_invoice.payment_request + assert external_invoice.checking_id + + preimage = "0000000000000000000000000000000000000000000000000000000000002107" + payment_reponse_pending = PaymentResponse( + ok=True, checking_id=external_invoice.checking_id, preimage=preimage + ) + mocker.patch( + "lnbits.wallets.FakeWallet.pay_invoice", + AsyncMock(return_value=payment_reponse_pending), + ) + ws_notification = mocker.patch( + "lnbits.core.services.send_payment_notification", + AsyncMock(return_value=None), + ) + wallet = await get_wallet(from_wallet.id) + assert wallet + balance_before = wallet.balance + await pay_invoice( + wallet_id=from_wallet.id, + payment_request=external_invoice.payment_request, + ) + assert ws_notification.call_count == 1, "Websocket notification sent." + + with pytest.raises(PaymentError, match="Payment already paid."): + await pay_invoice( + wallet_id=from_wallet.id, + payment_request=external_invoice.payment_request, + ) + + wallet = await get_wallet(from_wallet.id) + assert wallet + assert ( + balance_before - invoice_amount == wallet.balance + ), "Only one successful payment is subtracted." + + assert ws_notification.call_count == 1, "No new websocket notification sent." + + +@pytest.mark.asyncio +async def test_pay_external_invoice_success_bad_checking_id( + from_wallet: Wallet, mocker: MockerFixture, external_funding_source: FakeWallet +): + invoice_amount = 2108 + external_invoice = await external_funding_source.create_invoice(invoice_amount) + assert external_invoice.payment_request + assert external_invoice.checking_id + bad_checking_id = external_invoice.checking_id[::-1] + + preimage = "0000000000000000000000000000000000000000000000000000000000002108" + payment_reponse_pending = PaymentResponse( + ok=True, checking_id=bad_checking_id, preimage=preimage + ) + mocker.patch( + "lnbits.wallets.FakeWallet.pay_invoice", + AsyncMock(return_value=payment_reponse_pending), + ) + + await pay_invoice( + wallet_id=from_wallet.id, + payment_request=external_invoice.payment_request, + ) + + payment = await get_standalone_payment(bad_checking_id) + assert payment + assert payment.checking_id == bad_checking_id, "checking_id updated" + assert payment.payment_hash == external_invoice.checking_id + assert payment.amount == -2108_000 + assert payment.preimage == preimage + assert payment.status == PaymentState.SUCCESS.value + + +@pytest.mark.asyncio +async def test_no_checking_id( + from_wallet: Wallet, mocker: MockerFixture, external_funding_source: FakeWallet +): + invoice_amount = 2110 + external_invoice = await external_funding_source.create_invoice(invoice_amount) + assert external_invoice.payment_request + assert external_invoice.checking_id + + preimage = "0000000000000000000000000000000000000000000000000000000000002110" + payment_reponse_pending = PaymentResponse( + ok=True, checking_id=None, preimage=preimage + ) + mocker.patch( + "lnbits.wallets.FakeWallet.pay_invoice", + AsyncMock(return_value=payment_reponse_pending), + ) + + await pay_invoice( + wallet_id=from_wallet.id, + payment_request=external_invoice.payment_request, + ) + + payment = await get_standalone_payment(external_invoice.checking_id) + + assert payment + assert payment.checking_id == external_invoice.checking_id + assert payment.payment_hash == external_invoice.checking_id + assert payment.amount == -2110_000 + assert ( + payment.preimage + == "0000000000000000000000000000000000000000000000000000000000000000" + ) + assert payment.status == PaymentState.PENDING.value + + +@pytest.mark.asyncio +async def test_service_fee( + from_wallet: Wallet, + to_wallet: Wallet, + mocker: MockerFixture, + external_funding_source: FakeWallet, +): + invoice_amount = 2112 + external_invoice = await external_funding_source.create_invoice(invoice_amount) + assert external_invoice.payment_request + assert external_invoice.checking_id + + preimage = "0000000000000000000000000000000000000000000000000000000000002112" + payment_reponse_success = PaymentResponse( + ok=True, checking_id=external_invoice.checking_id, preimage=preimage + ) + mocker.patch( + "lnbits.wallets.FakeWallet.pay_invoice", + AsyncMock(return_value=payment_reponse_success), + ) + + settings.lnbits_service_fee_wallet = to_wallet.id + settings.lnbits_service_fee = 20 + + payment_hash = await pay_invoice( + wallet_id=from_wallet.id, + payment_request=external_invoice.payment_request, + ) + + payment = await get_standalone_payment(payment_hash) + assert payment + assert payment.status == PaymentState.SUCCESS.value + assert payment.checking_id == payment_hash + assert payment.amount == -2112_000 + assert payment.fee == -422_400 + assert payment.bolt11 == external_invoice.payment_request + assert payment.preimage == preimage + + service_fee_payment = await get_standalone_payment(f"service_fee_{payment_hash}") + assert service_fee_payment + assert service_fee_payment.status == PaymentState.SUCCESS.value + assert service_fee_payment.checking_id == f"service_fee_{payment_hash}" + assert service_fee_payment.amount == 422_400 + assert service_fee_payment.bolt11 == external_invoice.payment_request + assert ( + service_fee_payment.preimage + == "0000000000000000000000000000000000000000000000000000000000000000" + )