From f1c068a1150d4f1c63019fa48357b9eb8c05aa22 Mon Sep 17 00:00:00 2001 From: Charles Hill Date: Fri, 12 Nov 2021 02:02:13 -0500 Subject: [PATCH 1/9] Fix Dockerfile --- Dockerfile | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/Dockerfile b/Dockerfile index 960fbf75..243f298b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,7 @@ ENV PATH="$VIRTUAL_ENV/bin:$PATH" # Install build deps RUN apt-get update RUN apt-get install -y --no-install-recommends build-essential +RUN python -m pip install --upgrade pip # Install runtime deps COPY requirements.txt /tmp/requirements.txt @@ -18,7 +19,7 @@ RUN pip install -r /tmp/requirements.txt RUN pip install pylightning # Install LND specific deps -RUN pip install lndgrpc purerpc +RUN pip install lndgrpc # Production image FROM python:3.7-slim as lnbits @@ -31,18 +32,10 @@ ENV VIRTUAL_ENV="/opt/venv" COPY --from=builder --chown=1000:1000 $VIRTUAL_ENV $VIRTUAL_ENV ENV PATH="$VIRTUAL_ENV/bin:$PATH" -# Setup Quart -ENV QUART_APP="lnbits.app:create_app()" -ENV QUART_ENV="development" -ENV QUART_DEBUG="true" - -# App -ENV LNBITS_BIND="0.0.0.0:5000" - # Copy in app source WORKDIR /app COPY --chown=1000:1000 lnbits /app/lnbits EXPOSE 5000 -CMD quart assets && quart migrate && hypercorn -k trio --bind $LNBITS_BIND 'lnbits.app:create_app()' +CMD ["uvicorn", "lnbits.__main__:app", "--port", "5000", "--host", "0.0.0.0"] From 4e6c30a909dee3d2c54f65fec6ff568f9b3265d6 Mon Sep 17 00:00:00 2001 From: Charles Hill Date: Wed, 17 Nov 2021 10:53:32 -0600 Subject: [PATCH 2/9] Unit tests for FastAPI branch Run via `make test` --- .github/workflows/tests.yml | 62 ++++++--- .gitignore | 1 + Makefile | 9 ++ docs/devs/development.md | 14 ++ lnbits/extensions/bleskomat/exchange_rates.py | 19 +-- lnbits/extensions/bleskomat/lnurl_api.py | 4 +- lnbits/extensions/bleskomat/models.py | 3 +- pytest.ini | 3 +- tests/conftest.py | 30 ++++- tests/core/{test_views_api.py => __init__.py} | 0 tests/core/test_views.py | 6 - tests/core/views/__init__.py | 0 tests/core/views/test_generic.py | 7 + tests/extensions/__init__.py | 0 tests/extensions/bleskomat/__init__.py | 0 tests/extensions/bleskomat/conftest.py | 57 +++++++++ tests/extensions/bleskomat/test_lnurl_api.py | 120 ++++++++++++++++++ tests/helpers.py | 19 +++ tests/mocks.py | 36 ++++++ 19 files changed, 344 insertions(+), 46 deletions(-) rename tests/core/{test_views_api.py => __init__.py} (100%) delete mode 100644 tests/core/test_views.py create mode 100644 tests/core/views/__init__.py create mode 100644 tests/core/views/test_generic.py create mode 100644 tests/extensions/__init__.py create mode 100644 tests/extensions/bleskomat/__init__.py create mode 100644 tests/extensions/bleskomat/conftest.py create mode 100644 tests/extensions/bleskomat/test_lnurl_api.py create mode 100644 tests/helpers.py create mode 100644 tests/mocks.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9f114467..1d2826c9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -3,11 +3,11 @@ name: tests on: [push, pull_request] jobs: - build: + unit: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.7, 3.8] + python-version: [3.8] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} @@ -15,22 +15,44 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install dependencies + env: + VIRTUAL_ENV: ./venv + PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }} run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - name: Test with pytest - env: - LNBITS_BACKEND_WALLET_CLASS: LNPayWallet - LNBITS_FORCE_HTTPS: 0 - LNPAY_API_ENDPOINT: https://api.lnpay.co/v1/ - LNPAY_API_KEY: sak_gG5pSFZhFgOLHm26a8hcWvXKt98yd - LNPAY_ADMIN_KEY: waka_HqWfOoNE0TPqmQHSYErbF4n9 - LNPAY_INVOICE_KEY: waki_ZqFEbhrTyopuPlOZButZUw - LNPAY_READ_KEY: wakr_6IyTaNrvSeu3jbojSWt4ou6h - run: | - pip install pytest pytest-cov - pytest --cov=lnbits --cov-report=xml - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v1 - with: - file: ./coverage.xml + python -m venv ${{ env.VIRTUAL_ENV }} + ./venv/bin/python -m pip install --upgrade pip + ./venv/bin/pip install -r requirements.txt + ./venv/bin/pip install pytest pytest-asyncio requests trio mock + - name: Run tests + run: make test + # build: + # runs-on: ubuntu-latest + # strategy: + # matrix: + # python-version: [3.7, 3.8] + # steps: + # - uses: actions/checkout@v2 + # - name: Set up Python ${{ matrix.python-version }} + # uses: actions/setup-python@v1 + # with: + # python-version: ${{ matrix.python-version }} + # - name: Install dependencies + # run: | + # python -m pip install --upgrade pip + # pip install -r requirements.txt + # - name: Test with pytest + # env: + # LNBITS_BACKEND_WALLET_CLASS: LNPayWallet + # LNBITS_FORCE_HTTPS: 0 + # LNPAY_API_ENDPOINT: https://api.lnpay.co/v1/ + # LNPAY_API_KEY: sak_gG5pSFZhFgOLHm26a8hcWvXKt98yd + # LNPAY_ADMIN_KEY: waka_HqWfOoNE0TPqmQHSYErbF4n9 + # LNPAY_INVOICE_KEY: waki_ZqFEbhrTyopuPlOZButZUw + # LNPAY_READ_KEY: wakr_6IyTaNrvSeu3jbojSWt4ou6h + # run: | + # pip install pytest pytest-cov + # pytest --cov=lnbits --cov-report=xml + # - name: Upload coverage to Codecov + # uses: codecov/codecov-action@v1 + # with: + # file: ./coverage.xml diff --git a/.gitignore b/.gitignore index 79e10fb8..c5f1498c 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ __pycache__ .webassets-cache htmlcov test-reports +tests/data *.swo *.swp diff --git a/Makefile b/Makefile index 89fa12fb..300b81aa 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,5 @@ +.PHONY: test + all: format check requirements.txt format: prettier black @@ -26,3 +28,10 @@ Pipfile.lock: Pipfile requirements.txt: Pipfile.lock cat Pipfile.lock | jq -r '.default | map_values(.version) | to_entries | map("\(.key)\(.value)") | join("\n")' > requirements.txt + +test: + rm -rf ./tests/data + mkdir -p ./tests/data + LNBITS_DATA_FOLDER="./tests/data" \ + PYTHONUNBUFFERED=1 \ + ./venv/bin/pytest -s diff --git a/docs/devs/development.md b/docs/devs/development.md index 5a8cd214..85346d16 100644 --- a/docs/devs/development.md +++ b/docs/devs/development.md @@ -10,3 +10,17 @@ For developers ============== Thanks for contributing :) + + +Tests +===== + +This project has unit tests that help prevent regressions. Before you can run the tests, you must install a few dependencies: +```bash +./venv/bin/pip install pytest pytest-asyncio requests trio mock +``` + +Then to run the tests: +```bash +make test +``` diff --git a/lnbits/extensions/bleskomat/exchange_rates.py b/lnbits/extensions/bleskomat/exchange_rates.py index 928a2823..dcdaa220 100644 --- a/lnbits/extensions/bleskomat/exchange_rates.py +++ b/lnbits/extensions/bleskomat/exchange_rates.py @@ -65,15 +65,16 @@ async def fetch_fiat_exchange_rate(currency: str, provider: str): } url = exchange_rate_providers[provider]["api_url"] - for key in replacements.keys(): - url = url.replace("{" + key + "}", replacements[key]) + if url: + for key in replacements.keys(): + url = url.replace("{" + key + "}", replacements[key]) + async with httpx.AsyncClient() as client: + r = await client.get(url) + r.raise_for_status() + data = r.json() + else: + data = {} getter = exchange_rate_providers[provider]["getter"] - - async with httpx.AsyncClient() as client: - r = await client.get(url) - r.raise_for_status() - data = r.json() - rate = float(getter(data, replacements)) - + rate = float(getter(data, replacements)) return rate diff --git a/lnbits/extensions/bleskomat/lnurl_api.py b/lnbits/extensions/bleskomat/lnurl_api.py index 4faa0ee9..25ff0412 100644 --- a/lnbits/extensions/bleskomat/lnurl_api.py +++ b/lnbits/extensions/bleskomat/lnurl_api.py @@ -121,8 +121,8 @@ async def api_bleskomat_lnurl(req: Request): except LnurlHttpError as e: return {"status": "ERROR", "reason": str(e)} - except Exception: - traceback.print_exc() + except Exception as e: + print(str(e)) return {"status": "ERROR", "reason": "Unexpected error"} return {"status": "OK"} diff --git a/lnbits/extensions/bleskomat/models.py b/lnbits/extensions/bleskomat/models.py index 89aefe1f..267cc949 100644 --- a/lnbits/extensions/bleskomat/models.py +++ b/lnbits/extensions/bleskomat/models.py @@ -124,7 +124,8 @@ class BleskomatLnurl(BaseModel): ) except (ValueError, PermissionError, PaymentFailure) as e: raise LnurlValidationError("Failed to pay invoice: " + str(e)) - except Exception: + except Exception as e: + print(str(e)) raise LnurlValidationError("Unexpected error") async def use(self, conn) -> bool: diff --git a/pytest.ini b/pytest.ini index 5f4a13a2..33eea052 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,2 +1,3 @@ [pytest] -trio_mode = true +filterwarnings = + ignore::pytest.PytestCacheWarning diff --git a/tests/conftest.py b/tests/conftest.py index 7944fc01..127233c1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,12 +1,28 @@ +import asyncio import pytest - +from httpx import AsyncClient from lnbits.app import create_app +from lnbits.commands import migrate_databases +from lnbits.settings import HOST, PORT +import tests.mocks +# use session scope to run once before and once after all tests +@pytest.fixture(scope="session") +def app(): + # yield and pass the app to the test + app = create_app() + loop = asyncio.get_event_loop() + loop.run_until_complete(migrate_databases()) + yield app + # get the current event loop and gracefully stop any running tasks + loop = asyncio.get_event_loop() + loop.run_until_complete(loop.shutdown_asyncgens()) + loop.close() @pytest.fixture -async def client(): - app = create_app() - app.config["TESTING"] = True - - async with app.test_client() as client: - yield client +async def client(app): + client = AsyncClient(app=app, base_url=f'http://{HOST}:{PORT}') + # yield and pass the client to the test + yield client + # close the async client after the test has finished + await client.aclose() diff --git a/tests/core/test_views_api.py b/tests/core/__init__.py similarity index 100% rename from tests/core/test_views_api.py rename to tests/core/__init__.py diff --git a/tests/core/test_views.py b/tests/core/test_views.py deleted file mode 100644 index 5bdde581..00000000 --- a/tests/core/test_views.py +++ /dev/null @@ -1,6 +0,0 @@ -import pytest - - -async def test_homepage(client): - r = await client.get("/") - assert b"Add a new wallet" in await r.get_data() diff --git a/tests/core/views/__init__.py b/tests/core/views/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/core/views/test_generic.py b/tests/core/views/test_generic.py new file mode 100644 index 00000000..4917cde4 --- /dev/null +++ b/tests/core/views/test_generic.py @@ -0,0 +1,7 @@ +import pytest +from tests.conftest import client + +@pytest.mark.asyncio +async def test_core_views_generic(client): + response = await client.get("/") + assert response.status_code == 200 diff --git a/tests/extensions/__init__.py b/tests/extensions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/extensions/bleskomat/__init__.py b/tests/extensions/bleskomat/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/extensions/bleskomat/conftest.py b/tests/extensions/bleskomat/conftest.py new file mode 100644 index 00000000..924998a7 --- /dev/null +++ b/tests/extensions/bleskomat/conftest.py @@ -0,0 +1,57 @@ +import json +import pytest +import secrets +from lnbits.core.crud import create_account, create_wallet +from lnbits.extensions.bleskomat.crud import create_bleskomat, create_bleskomat_lnurl +from lnbits.extensions.bleskomat.models import CreateBleskomat +from lnbits.extensions.bleskomat.helpers import generate_bleskomat_lnurl_secret, generate_bleskomat_lnurl_signature, prepare_lnurl_params, query_to_signing_payload +from lnbits.extensions.bleskomat.exchange_rates import exchange_rate_providers + +exchange_rate_providers["dummy"] = { + "name": "dummy", + "domain": None, + "api_url": None, + "getter": lambda data, replacements: str(1e8),# 1 BTC = 100000000 sats +} + +@pytest.fixture +async def bleskomat(): + user = await create_account() + wallet = await create_wallet(user_id=user.id, wallet_name="bleskomat_test") + data = CreateBleskomat( + name="Test Bleskomat", + fiat_currency="EUR", + exchange_rate_provider="dummy", + fee="0" + ) + bleskomat = await create_bleskomat(data=data, wallet_id=wallet.id) + return bleskomat + +@pytest.fixture +async def lnurl(bleskomat): + query = { + "tag": "withdrawRequest", + "nonce": secrets.token_hex(10), + "tag": "withdrawRequest", + "minWithdrawable": "50000", + "maxWithdrawable": "50000", + "defaultDescription": "test valid sig", + } + tag = query["tag"] + params = prepare_lnurl_params(tag, query) + payload = query_to_signing_payload(query) + signature = generate_bleskomat_lnurl_signature( + payload=payload, + api_key_secret=bleskomat.api_key_secret, + api_key_encoding=bleskomat.api_key_encoding + ) + secret = generate_bleskomat_lnurl_secret(bleskomat.api_key_id, signature) + params = json.JSONEncoder().encode(params) + lnurl = await create_bleskomat_lnurl( + bleskomat=bleskomat, secret=secret, tag=tag, params=params, uses=1 + ) + return { + "bleskomat": bleskomat, + "lnurl": lnurl, + "secret": secret, + } diff --git a/tests/extensions/bleskomat/test_lnurl_api.py b/tests/extensions/bleskomat/test_lnurl_api.py new file mode 100644 index 00000000..969550cd --- /dev/null +++ b/tests/extensions/bleskomat/test_lnurl_api.py @@ -0,0 +1,120 @@ +import pytest +import secrets +from lnbits.core.crud import get_wallet +from lnbits.settings import HOST, PORT +from lnbits.extensions.bleskomat.crud import get_bleskomat_lnurl +from lnbits.extensions.bleskomat.helpers import generate_bleskomat_lnurl_signature, query_to_signing_payload +from tests.conftest import client +from tests.helpers import credit_wallet +from tests.extensions.bleskomat.conftest import bleskomat, lnurl +from tests.mocks import WALLET + +@pytest.mark.asyncio +async def test_bleskomat_lnurl_api_missing_secret(client): + response = await client.get("/bleskomat/u") + assert response.status_code == 200 + assert response.json() == {"status": "ERROR", "reason": "Missing secret"} + +@pytest.mark.asyncio +async def test_bleskomat_lnurl_api_invalid_secret(client): + response = await client.get("/bleskomat/u?k1=invalid-secret") + assert response.status_code == 200 + assert response.json() == {"status": "ERROR", "reason": "Invalid secret"} + +@pytest.mark.asyncio +async def test_bleskomat_lnurl_api_unknown_api_key(client): + query = { + "id": "does-not-exist", + "nonce": secrets.token_hex(10), + "tag": "withdrawRequest", + "minWithdrawable": "1", + "maxWithdrawable": "1", + "defaultDescription": "", + "f": "EUR", + } + payload = query_to_signing_payload(query) + signature = "xxx"# not checked, so doesn't matter + response = await client.get(f'/bleskomat/u?{payload}&signature={signature}') + assert response.status_code == 200 + assert response.json() == {"status": "ERROR", "reason": "Unknown API key"} + +@pytest.mark.asyncio +async def test_bleskomat_lnurl_api_invalid_signature(client, bleskomat): + query = { + "id": bleskomat.api_key_id, + "nonce": secrets.token_hex(10), + "tag": "withdrawRequest", + "minWithdrawable": "1", + "maxWithdrawable": "1", + "defaultDescription": "", + "f": "EUR", + } + payload = query_to_signing_payload(query) + signature = "invalid" + response = await client.get(f'/bleskomat/u?{payload}&signature={signature}') + assert response.status_code == 200 + assert response.json() == {"status": "ERROR", "reason": "Invalid API key signature"} + +@pytest.mark.asyncio +async def test_bleskomat_lnurl_api_valid_signature(client, bleskomat): + query = { + "id": bleskomat.api_key_id, + "nonce": secrets.token_hex(10), + "tag": "withdrawRequest", + "minWithdrawable": "1", + "maxWithdrawable": "1", + "defaultDescription": "test valid sig", + "f": "EUR",# tests use the dummy exchange rate provider + } + payload = query_to_signing_payload(query) + signature = generate_bleskomat_lnurl_signature( + payload=payload, + api_key_secret=bleskomat.api_key_secret, + api_key_encoding=bleskomat.api_key_encoding + ) + response = await client.get(f'/bleskomat/u?{payload}&signature={signature}') + assert response.status_code == 200 + data = response.json() + assert data["tag"] == "withdrawRequest" + assert data["minWithdrawable"] == 1000 + assert data["maxWithdrawable"] == 1000 + assert data["defaultDescription"] == "test valid sig" + assert data["callback"] == f'http://{HOST}:{PORT}/bleskomat/u' + k1 = data["k1"] + lnurl = await get_bleskomat_lnurl(secret=k1) + assert lnurl + +@pytest.mark.asyncio +async def test_bleskomat_lnurl_api_action_insufficient_balance(client, lnurl): + bleskomat = lnurl["bleskomat"] + secret = lnurl["secret"] + pr = "lntb500n1pseq44upp5xqd38rgad72lnlh4gl339njlrsl3ykep82j6gj4g02dkule7k54qdqqcqzpgxqyz5vqsp5h0zgewuxdxcl2rnlumh6g520t4fr05rgudakpxm789xgjekha75s9qyyssq5vhwsy9knhfeqg0wn6hcnppwmum8fs3g3jxkgw45havgfl6evchjsz3s8e8kr6eyacz02szdhs7v5lg0m7wehd5rpf6yg8480cddjlqpae52xu" + WALLET.pay_invoice.reset_mock() + response = await client.get(f'/bleskomat/u?k1={secret}&pr={pr}') + assert response.status_code == 200 + assert response.json() == {"status": "ERROR", "reason": "Failed to pay invoice: Insufficient balance."} + wallet = await get_wallet(bleskomat.wallet) + assert wallet.balance_msat == 0 + bleskomat_lnurl = await get_bleskomat_lnurl(secret) + assert bleskomat_lnurl.has_uses_remaining() == True + WALLET.pay_invoice.assert_not_called() + +@pytest.mark.asyncio +async def test_bleskomat_lnurl_api_action_success(client, lnurl): + bleskomat = lnurl["bleskomat"] + secret = lnurl["secret"] + pr = "lntb500n1pseq44upp5xqd38rgad72lnlh4gl339njlrsl3ykep82j6gj4g02dkule7k54qdqqcqzpgxqyz5vqsp5h0zgewuxdxcl2rnlumh6g520t4fr05rgudakpxm789xgjekha75s9qyyssq5vhwsy9knhfeqg0wn6hcnppwmum8fs3g3jxkgw45havgfl6evchjsz3s8e8kr6eyacz02szdhs7v5lg0m7wehd5rpf6yg8480cddjlqpae52xu" + await credit_wallet( + wallet_id=bleskomat.wallet, + amount=100000, + ) + wallet = await get_wallet(bleskomat.wallet) + assert wallet.balance_msat == 100000 + WALLET.pay_invoice.reset_mock() + response = await client.get(f'/bleskomat/u?k1={secret}&pr={pr}') + assert response.json() == {"status": "OK"} + wallet = await get_wallet(bleskomat.wallet) + assert wallet.balance_msat == 50000 + bleskomat_lnurl = await get_bleskomat_lnurl(secret) + assert bleskomat_lnurl.has_uses_remaining() == False + WALLET.pay_invoice.assert_called_once_with(pr) diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 00000000..1687e25d --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,19 @@ +import hashlib +import secrets +from lnbits.core.crud import create_payment + +async def credit_wallet(wallet_id: str, amount: int): + preimage = secrets.token_hex(32) + m = hashlib.sha256() + m.update(f"{preimage}".encode()) + payment_hash = m.hexdigest() + await create_payment( + wallet_id=wallet_id, + payment_request="", + payment_hash=payment_hash, + checking_id=payment_hash, + preimage=preimage, + memo="", + amount=amount,# msat + pending=False,# not pending, so it will increase the wallet's balance + ) diff --git a/tests/mocks.py b/tests/mocks.py new file mode 100644 index 00000000..5b20824c --- /dev/null +++ b/tests/mocks.py @@ -0,0 +1,36 @@ +from mock import AsyncMock +from lnbits import bolt11 +from lnbits.wallets.base import ( + StatusResponse, + InvoiceResponse, + PaymentResponse, + PaymentStatus, + Wallet, +) +from lnbits.settings import WALLET + +WALLET.status = AsyncMock(return_value=StatusResponse( + "",# no error + 1000000,# msats +)) +WALLET.create_invoice = AsyncMock(return_value=InvoiceResponse( + True,# ok + "6621aafbdd7709ca6eea6203f362d64bd7cb2911baa91311a176b3ecaf2274bd",# checking_id (i.e. payment_hash) + "lntb1u1psezhgspp5vcs6477awuyu5mh2vgplxckkf0tuk2g3h253xydpw6e7etezwj7sdqqcqzpgxqyz5vqsp5dxpw8zs77hw5pla4wz4mfujllyxtlpu443auur2uxqdrs8q2h56q9qyyssq65zk30ylmygvv5y4tuwalnf3ttnqjn57ef6rmcqg0s53akem560jh8ptemjcmytn3lrlatw4hv9smg88exv3v4f4lqnp96s0psdrhxsp6pp75q",# payment_request + "",# no error +)) +def pay_invoice_side_effect(payment_request: str): + invoice = bolt11.decode(payment_request) + return PaymentResponse( + True,# ok + invoice.payment_hash,# checking_id (i.e. payment_hash) + 0,# fee_msat + "",# no error + ) +WALLET.pay_invoice = AsyncMock(side_effect=pay_invoice_side_effect) +WALLET.get_invoice_status = AsyncMock(return_value=PaymentStatus( + True,# paid +)) +WALLET.get_payment_status = AsyncMock(return_value=PaymentStatus( + True,# paid +)) From 43b48376bb778ebc9eb71e6b541c49003f89b3d5 Mon Sep 17 00:00:00 2001 From: calle <93376500+callebtc@users.noreply.github.com> Date: Thu, 9 Dec 2021 00:26:08 +0100 Subject: [PATCH 3/9] lndrest fees for fastapi (#459) --- lnbits/wallets/lndrest.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lnbits/wallets/lndrest.py b/lnbits/wallets/lndrest.py index e3addfd6..4f7ee526 100644 --- a/lnbits/wallets/lndrest.py +++ b/lnbits/wallets/lndrest.py @@ -117,8 +117,9 @@ class LndRestWallet(Wallet): data = r.json() payment_hash = data["payment_hash"] checking_id = payment_hash + fee_msat = int(data["payment_route"]["total_fees_msat"]) preimage = base64.b64decode(data["payment_preimage"]).hex() - return PaymentResponse(True, checking_id, 0, preimage, None) + return PaymentResponse(True, checking_id, fee_msat, preimage, None) async def get_invoice_status(self, checking_id: str) -> PaymentStatus: checking_id = checking_id.replace("_", "/") From d00bd9a995bd007de958b6ca0ed061340c29c4c8 Mon Sep 17 00:00:00 2001 From: benarc Date: Thu, 9 Dec 2021 10:32:03 +0000 Subject: [PATCH 4/9] lnul display without refresh --- lnbits/extensions/lnurlp/static/js/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lnbits/extensions/lnurlp/static/js/index.js b/lnbits/extensions/lnurlp/static/js/index.js index 72477347..e18d6161 100644 --- a/lnbits/extensions/lnurlp/static/js/index.js +++ b/lnbits/extensions/lnurlp/static/js/index.js @@ -166,7 +166,7 @@ new Vue({ LNbits.api .request('POST', '/lnurlp/api/v1/links', wallet.adminkey, data) .then(response => { - this.payLinks.push(mapPayLink(response.data)) + this.getPayLinks() this.formDialog.show = false this.resetFormData() }) From 09990450b79d9cd524bd3054572a8c44574537bf Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Tue, 14 Dec 2021 17:00:18 -0300 Subject: [PATCH 5/9] lnurlpos v2 lnurl endpoint. compatible with the phone app and updated device. --- lnbits/extensions/lnurlpos/lnurl.py | 117 +++++++++++++----- lnbits/extensions/lnurlpos/models.py | 10 -- .../templates/lnurlpos/_api_docs.html | 2 +- .../lnurlpos/templates/lnurlpos/index.html | 3 +- lnbits/utils/exchange_rates.py | 1 + 5 files changed, 88 insertions(+), 45 deletions(-) diff --git a/lnbits/extensions/lnurlpos/lnurl.py b/lnbits/extensions/lnurlpos/lnurl.py index 69164824..0369b0aa 100644 --- a/lnbits/extensions/lnurlpos/lnurl.py +++ b/lnbits/extensions/lnurlpos/lnurl.py @@ -1,9 +1,10 @@ +import base64 import hashlib from http import HTTPStatus +from typing import Optional from fastapi import Request from fastapi.param_functions import Query -from lnurl import LnurlPayActionResponse, LnurlPayResponse # type: ignore from starlette.exceptions import HTTPException from lnbits.core.services import create_invoice @@ -28,53 +29,101 @@ async def lnurl_response( nonce: str = Query(None), pos_id: str = Query(None), payload: str = Query(None), +): + return await handle_lnurl_firstrequest( + request, pos_id, nonce, payload, verify_checksum=False + ) + + +@lnurlpos_ext.get( + "/api/v2/lnurl/{pos_id}", + status_code=HTTPStatus.OK, + name="lnurlpos.lnurl_v2_params", +) +async def lnurl_v2_params( + request: Request, + pos_id: str = Query(None), + n: str = Query(None), + p: str = Query(None), +): + return await handle_lnurl_firstrequest(request, pos_id, n, p, verify_checksum=True) + + +async def handle_lnurl_firstrequest( + request: Request, pos_id: str, nonce: str, payload: str, verify_checksum: bool ): pos = await get_lnurlpos(pos_id) if not pos: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="lnurlpos not found." - ) - nonce1 = bytes.fromhex(nonce) - payload1 = bytes.fromhex(payload) - h = hashlib.sha256(nonce1) + return { + "status": "ERROR", + "reason": f"lnurlpos {pos_id} not found on this server.", + } + + try: + nonceb = bytes.fromhex(nonce) + except ValueError: + try: + nonce += "=" * ((4 - len(nonce) % 4) % 4) + nonceb = base64.urlsafe_b64decode(nonce) + except: + return { + "status": "ERROR", + "reason": f"Invalid hex or base64 nonce: {nonce}", + } + + try: + payloadb = bytes.fromhex(payload) + except ValueError: + try: + payload += "=" * ((4 - len(payload) % 4) % 4) + payloadb = base64.urlsafe_b64decode(payload) + except: + return { + "status": "ERROR", + "reason": f"Invalid hex or base64 payload: {payload}", + } + + h = hashlib.sha256(nonceb) h.update(pos.key.encode()) s = h.digest() - res = bytearray(payload1) + + res = bytearray(payloadb) for i in range(len(res)): res[i] = res[i] ^ s[i] - decryptedAmount = float(int.from_bytes(res[2:6], "little") / 100) - decryptedPin = int.from_bytes(res[:2], "little") - if type(decryptedAmount) != float: - raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not an amount.") + + if verify_checksum: + checksum = res[6:8] + if hashlib.sha256(res[0:6]).digest()[0:2] != checksum: + return {"status": "ERROR", "reason": "Invalid checksum!"} + + pin = int.from_bytes(res[0:2], "little") + amount = int.from_bytes(res[2:6], "little") + price_msat = ( - await fiat_amount_as_satoshis(decryptedAmount, pos.currency) + await fiat_amount_as_satoshis(float(amount) / 100, pos.currency) if pos.currency != "sat" - else pos.currency + else amount ) * 1000 lnurlpospayment = await create_lnurlpospayment( posid=pos.id, payload=payload, sats=price_msat, - pin=decryptedPin, + pin=pin, payhash="payment_hash", ) - if not lnurlpospayment: - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, detail="Could not create payment" - ) + return {"status": "ERROR", "reason": "Could not create payment."} - resp = LnurlPayResponse( - callback=request.url_for( + return { + "tag": "payRequest", + "callback": request.url_for( "lnurlpos.lnurl_callback", paymentid=lnurlpospayment.id ), - min_sendable=price_msat, - max_sendable=price_msat, - metadata=await pos.lnurlpay_metadata(), - ) - - return resp.dict() + "minSendable": price_msat, + "maxSendable": price_msat, + "metadata": await pos.lnurlpay_metadata(), + } @lnurlpos_ext.get( @@ -102,10 +151,14 @@ async def lnurl_callback(request: Request, paymentid: str = Query(None)): lnurlpospayment_id=paymentid, payhash=payment_hash ) - resp = LnurlPayActionResponse( - pr=payment_request, - success_action=pos.success_action(paymentid, request), - routes=[], - ) + return { + "pr": payment_request, + "successAction": { + "tag": "url", + "description": "Check the attached link", + "url": req.url_for("lnurlpos.displaypin", paymentid=paymentid), + }, + "routes": [], + } return resp.dict() diff --git a/lnbits/extensions/lnurlpos/models.py b/lnbits/extensions/lnurlpos/models.py index 4cb9fa8c..a8a299e2 100644 --- a/lnbits/extensions/lnurlpos/models.py +++ b/lnbits/extensions/lnurlpos/models.py @@ -35,16 +35,6 @@ class lnurlposs(BaseModel): async def lnurlpay_metadata(self) -> LnurlPayMetadata: return LnurlPayMetadata(json.dumps([["text/plain", self.title]])) - def success_action( - self, paymentid: str, req: Request - ) -> Optional[LnurlPaySuccessAction]: - - return UrlAction( - url=req.url_for("lnurlpos.displaypin", paymentid=paymentid), - description="Check the attached link", - ) - - class lnurlpospayment(BaseModel): id: str posid: str diff --git a/lnbits/extensions/lnurlpos/templates/lnurlpos/_api_docs.html b/lnbits/extensions/lnurlpos/templates/lnurlpos/_api_docs.html index c4960d64..470d2248 100644 --- a/lnbits/extensions/lnurlpos/templates/lnurlpos/_api_docs.html +++ b/lnbits/extensions/lnurlpos/templates/lnurlpos/_api_docs.html @@ -1,7 +1,7 @@

- Register LNURLPoS devices to recieve payments in your LNbits wallet.
+ Register LNURLPoS devices to receive payments in your LNbits wallet.
Build your own here https://github.com/arcbtc/LNURLPoS

Copy to LNURLPoS device
- {% raw %} String server = "{{location}}";
- String posId = "{{settingsDialog.data.id}}";
+ {% raw %} String server = "{{location}}/lnurlpos/api/v2/lnurl/{{settingsDialog.data.id}}";
String key = "{{settingsDialog.data.key}}";
String currency = "{{settingsDialog.data.currency}}";{% endraw %}
diff --git a/lnbits/utils/exchange_rates.py b/lnbits/utils/exchange_rates.py index 8d150996..39e6a73a 100644 --- a/lnbits/utils/exchange_rates.py +++ b/lnbits/utils/exchange_rates.py @@ -253,6 +253,7 @@ async def btc_price(currency: str) -> float: await send_channel.put(rate) except ( TypeError, # CoinMate returns HTTPStatus 200 but no data when a currency pair is not found + KeyError, # Kraken's response dictionary doesn't include keys we look up for httpx.ConnectTimeout, httpx.ConnectError, httpx.ReadTimeout, From 4f6e49b02f08cfbd70f7479d87fdbfdf1ca79903 Mon Sep 17 00:00:00 2001 From: benarc Date: Wed, 15 Dec 2021 14:47:45 +0000 Subject: [PATCH 6/9] added Stepans fix --- lnbits/extensions/lnurlpos/lnurl.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lnbits/extensions/lnurlpos/lnurl.py b/lnbits/extensions/lnurlpos/lnurl.py index 0369b0aa..e61fc900 100644 --- a/lnbits/extensions/lnurlpos/lnurl.py +++ b/lnbits/extensions/lnurlpos/lnurl.py @@ -83,9 +83,12 @@ async def handle_lnurl_firstrequest( "reason": f"Invalid hex or base64 payload: {payload}", } - h = hashlib.sha256(nonceb) - h.update(pos.key.encode()) - s = h.digest() + if len(payloadb)!=8: + raise RuntimeError("Expected 8 bytes") + expected = hmac.new(pos.key.encode(), payloadb[:-2], digestmod="sha256").digest() + if expected[:2] != payloadb[-2:]: + raise RuntimeError("Invalid HMAC") + s = hmac.new(pos.key.encode(), nonceb, digestmod="sha256").digest() res = bytearray(payloadb) for i in range(len(res)): From b570d82a685f2bda23c6a086af22e2259529157e Mon Sep 17 00:00:00 2001 From: Ben Arc Date: Thu, 16 Dec 2021 17:01:45 +0000 Subject: [PATCH 7/9] Formatted --- lnbits/extensions/lnurlpos/templates/lnurlpos/index.html | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lnbits/extensions/lnurlpos/templates/lnurlpos/index.html b/lnbits/extensions/lnurlpos/templates/lnurlpos/index.html index ac7b84cb..3cf4c620 100644 --- a/lnbits/extensions/lnurlpos/templates/lnurlpos/index.html +++ b/lnbits/extensions/lnurlpos/templates/lnurlpos/index.html @@ -127,10 +127,14 @@ position="top" @hide="closeFormDialog" > - +
Copy to LNURLPoS device
- {% raw %} String server = "{{location}}/lnurlpos/api/v2/lnurl/{{settingsDialog.data.id}}";
+ {% raw %} String server = + "{{location}}/lnurlpos/api/v1/lnurl/{{settingsDialog.data.id}}";
String key = "{{settingsDialog.data.key}}";
String currency = "{{settingsDialog.data.currency}}";{% endraw %}
From 13a34d78e2d676261f5fd178e58579d839393219 Mon Sep 17 00:00:00 2001 From: Ben Arc Date: Thu, 16 Dec 2021 17:07:23 +0000 Subject: [PATCH 8/9] server to baseURL --- lnbits/extensions/lnurlpos/templates/lnurlpos/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lnbits/extensions/lnurlpos/templates/lnurlpos/index.html b/lnbits/extensions/lnurlpos/templates/lnurlpos/index.html index 3cf4c620..79a6d457 100644 --- a/lnbits/extensions/lnurlpos/templates/lnurlpos/index.html +++ b/lnbits/extensions/lnurlpos/templates/lnurlpos/index.html @@ -133,7 +133,7 @@ >
Copy to LNURLPoS device
- {% raw %} String server = + {% raw %} String baseURL = "{{location}}/lnurlpos/api/v1/lnurl/{{settingsDialog.data.id}}";
String key = "{{settingsDialog.data.key}}";
String currency = "{{settingsDialog.data.currency}}";{% endraw %} From 703e6107fa8447222a29ec76c314631fceb9869c Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Thu, 16 Dec 2021 15:55:36 -0300 Subject: [PATCH 9/9] remove old fiatjaf's checksum thing (replaced with stepan's). --- lnbits/extensions/lnurlpos/lnurl.py | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/lnbits/extensions/lnurlpos/lnurl.py b/lnbits/extensions/lnurlpos/lnurl.py index e61fc900..dccacef0 100644 --- a/lnbits/extensions/lnurlpos/lnurl.py +++ b/lnbits/extensions/lnurlpos/lnurl.py @@ -56,7 +56,7 @@ async def handle_lnurl_firstrequest( if not pos: return { "status": "ERROR", - "reason": f"lnurlpos {pos_id} not found on this server.", + "reason": f"lnurlpos {pos_id} not found on this server", } try: @@ -83,22 +83,24 @@ async def handle_lnurl_firstrequest( "reason": f"Invalid hex or base64 payload: {payload}", } - if len(payloadb)!=8: - raise RuntimeError("Expected 8 bytes") - expected = hmac.new(pos.key.encode(), payloadb[:-2], digestmod="sha256").digest() - if expected[:2] != payloadb[-2:]: - raise RuntimeError("Invalid HMAC") - s = hmac.new(pos.key.encode(), nonceb, digestmod="sha256").digest() + # check payload and nonce sizes + if len(payloadb) != 8 or len(nonceb) != 8: + return {"status": "ERROR", "reason": "Expected 8 bytes"} + # verify hmac + if verify_checksum: + expected = hmac.new( + pos.key.encode(), payloadb[:-2], digestmod="sha256" + ).digest() + if expected[:2] != payloadb[-2:]: + return {"status": "ERROR", "reason": "Invalid HMAC"} + + # decrypt + s = hmac.new(pos.key.encode(), nonceb, digestmod="sha256").digest() res = bytearray(payloadb) for i in range(len(res)): res[i] = res[i] ^ s[i] - if verify_checksum: - checksum = res[6:8] - if hashlib.sha256(res[0:6]).digest()[0:2] != checksum: - return {"status": "ERROR", "reason": "Invalid checksum!"} - pin = int.from_bytes(res[0:2], "little") amount = int.from_bytes(res[2:6], "little")