Compare commits

..
103 Commits
Author SHA1 Message Date
dni ⚡ 91ea747a0e rc5 2024-10-18 09:10:55 +02:00
dni ⚡ 5f32cfc053 revert fiat amounts :( 2024-10-18 09:08:00 +02:00
f5e59cdf49 refactor: just refactor (#2740)
---------

Co-authored-by: dni  <office@dnilabs.com>
2024-10-18 08:45:08 +02:00
dni ⚡ ee93d2f275 fixup! 2024-10-17 11:48:42 +02:00
dni ⚡ 6ffe2de8a9 merge tets from vlad 2024-10-17 11:45:23 +02:00
dni ⚡ 8596fc6faa fixup! 2024-10-17 10:43:05 +02:00
dni ⚡ aa98e38dfc rename to get_user_from_account 2024-10-17 10:43:04 +02:00
dni ⚡ deee7d80be refactor services
- add PaymentFiatAmount
- return Payment on api endpoints
2024-10-17 10:43:03 +02:00
dni ⚡ 33bc380594 traces for value and assertion errors 2024-10-17 10:42:29 +02:00
dni ⚡ 3a1064c841 update migrations.py 2024-10-17 10:42:28 +02:00
Vlad Stananddni ⚡ 75698bfd5c fix: json stringify 2024-10-17 10:42:27 +02:00
dni ⚡ 1330290713 rc4 2024-10-17 10:42:26 +02:00
Vlad Stananddni ⚡ fe43117456 fix: init DB 2024-10-17 10:42:24 +02:00
dni ⚡ 2b869cb03f remove update_payment_status/extra/details 2024-10-17 10:42:23 +02:00
dni ⚡ 6d6a35989c add dbversion model 2024-10-17 10:40:59 +02:00
dni ⚡ b03793c196 make get_user better 2024-10-17 10:40:57 +02:00
dni ⚡ 4ca558be54 clean init admin settings 2024-10-17 10:40:56 +02:00
dni ⚡ 578e031b17 vlad 2024-10-17 10:40:55 +02:00
dni ⚡ 4a102e2e85 fix qrscanner 2024-10-17 10:40:54 +02:00
dni ⚡ ff6d53afcb fix dynamic chips 2024-10-17 10:40:52 +02:00
dni ⚡ 5f328539ff qrcode 2024-10-17 10:40:51 +02:00
dni ⚡ 60d5222ea6 fix extra null 2024-10-17 10:40:50 +02:00
dni ⚡ a42e4a7175 fix where 2024-10-17 10:40:48 +02:00
dni ⚡ fe8bd4ef53 rc3 2024-10-17 10:40:47 +02:00
Tiago Vasconcelosanddni ⚡ 602980f53b added thi one also DNI
remove it if not needed
2024-10-17 10:40:46 +02:00
Tiago Vasconcelosanddni ⚡ 1eb991442f attempt to make chip component in settings dynamic fields
it's not updating data
2024-10-17 10:40:45 +02:00
Tiago Vasconcelosanddni ⚡ fa3e1088ce screwed up and made more css pushes to this branch 2024-10-17 10:40:43 +02:00
Tiago Vasconcelosanddni ⚡ 3748a0f632 remove shadow on cards in dark theme 2024-10-17 10:40:42 +02:00
dni ⚡ 88058fa69b fix dynamic fields and ext db migration 2024-10-17 10:40:41 +02:00
Vlad Stananddni ⚡ 26d557e06c fix: component name 2024-10-17 10:40:38 +02:00
Vlad Stananddni ⚡ dd51aeb91d chore: code format 2024-10-17 10:40:37 +02:00
Vlad Stananddni ⚡ 0105e67989 fix: payment extra 2024-10-17 10:40:36 +02:00
Vlad Stananddni ⚡ 7dd1cb44a9 fix: payment extra 2024-10-17 10:40:35 +02:00
Vlad Stananddni ⚡ 54f96c3fea chore: code format 2024-10-17 10:40:33 +02:00
Vlad Stananddni ⚡ 06a686a2f6 fix: payment extra and payment hash 2024-10-17 10:40:32 +02:00
Vlad Stananddni ⚡ e4416878c2 chore: log message fix 2024-10-17 10:40:31 +02:00
dni ⚡ f0752011e8 vue3 @input migration 2024-10-17 10:40:29 +02:00
dni ⚡ 19244de979 extension toggle vue3 2024-10-17 10:40:28 +02:00
dni ⚡ 7caf577d4b payments 2024-10-17 10:40:27 +02:00
Vlad Stananddni ⚡ c0500cdcb3 fix: lint 2024-10-17 10:40:26 +02:00
Vlad Stananddni ⚡ d42772357b chore: code format 2024-10-17 10:40:24 +02:00
Vlad Stananddni ⚡ a43cf0ce10 feat: add JSON custom serializer 2024-10-17 10:40:23 +02:00
Vlad Stananddni ⚡ 4147166fad fix: cast balance to int 2024-10-17 10:40:22 +02:00
Vlad Stananddni ⚡ ab5a43799b fix: time column 2024-10-17 10:40:20 +02:00
Vlad Stananddni ⚡ 31af34fac9 fix: empty currency 2024-10-17 10:40:19 +02:00
dni ⚡ b75305caad fixup! 2024-10-17 10:40:18 +02:00
dni ⚡ 6a2cd95d3c add wallet balance 2024-10-17 10:40:17 +02:00
dni ⚡ d1b0e2380b use db url 2024-10-17 10:40:15 +02:00
dni ⚡ 1f63b94a56 escape user 2024-10-17 10:40:14 +02:00
Vlad Stananddni ⚡ 4abcb8a401 chore: remove verbose key names 2024-10-17 10:40:13 +02:00
Vlad Stananddni ⚡ 9c748dda72 chore: format 2024-10-17 10:40:06 +02:00
Vlad Stananddni ⚡ 7bad0552f6 refactor: make settings a fixture 2024-10-17 10:39:57 +02:00
Vlad Stananddni ⚡ b33abddf3a fix: settings 2024-10-17 10:38:43 +02:00
Vlad Stananddni ⚡ 4aae00c4fa fix: api tests 2024-10-17 10:37:46 +02:00
Vlad Stananddni ⚡ 4361fc7cc2 fix: add back create_user_account 2024-10-17 10:37:45 +02:00
Vlad Stananddni ⚡ 376081b369 fix: some tests 2024-10-17 10:37:43 +02:00
dni ⚡ b4c57c0faf parse date from dict 2024-10-17 10:37:42 +02:00
dni ⚡ edd72fd5db add migration for view 2024-10-17 10:37:41 +02:00
dni ⚡ 5f848a2c21 fixes 2024-10-17 10:37:38 +02:00
dni ⚡ 2a928a41bf fix 2024-10-17 10:37:37 +02:00
Vlad Stananddni ⚡ 4c5bcc3084 fix: account insert 2024-10-17 10:37:36 +02:00
dni ⚡ 3457cd1f33 fixup! 2024-10-17 10:37:34 +02:00
dni ⚡ f6d87e3c36 fixup! 2024-10-17 10:37:33 +02:00
dni ⚡ a58a698918 fixup! 2024-10-17 10:37:32 +02:00
dni ⚡ 1ed581d2ac fixup migration 2024-10-17 10:37:14 +02:00
dni ⚡ be72e35914 fixup! 2024-10-17 10:37:13 +02:00
dni ⚡ 4e3c19f277 fixup! 2024-10-17 10:37:11 +02:00
dni ⚡ 65bd26736c fixup! 2024-10-17 10:37:10 +02:00
dni ⚡ 441348f1ed fix some tests 2024-10-17 10:37:08 +02:00
dni ⚡ 3cdcb8d815 fixup! 2024-10-17 10:37:06 +02:00
dni ⚡ 595e0bf070 fixup! 2024-10-17 10:37:04 +02:00
dni ⚡ ce920d7bd1 fixup! 2024-10-17 10:37:01 +02:00
dni ⚡ 38c0bfc91c fixup! 2024-10-17 10:37:00 +02:00
dni ⚡ 275a2da4ac backwards compatible 2024-10-17 10:36:59 +02:00
dni ⚡ 6e157640ee fix account overview 2024-10-17 10:36:58 +02:00
dni ⚡ 0faa53090a fix error 2024-10-17 10:36:56 +02:00
dni ⚡ 9fee6699f1 fixup! 2024-10-17 10:36:55 +02:00
dni ⚡ 1718031be2 fixup-qrcode 2024-10-17 10:36:54 +02:00
dni ⚡ f8d04d0637 some wallet ui fixes 2024-10-17 10:36:53 +02:00
dni ⚡ c001d50553 fixup! 2024-10-17 10:36:51 +02:00
dni ⚡ cc37dd35fe revert 2024-10-17 10:36:50 +02:00
dni ⚡ 88f3fb2807 fixup! 2024-10-17 10:36:49 +02:00
dni ⚡ 63419cb900 more fixes 2024-10-17 10:36:47 +02:00
dni ⚡ 3710242891 fixup! 2024-10-17 10:36:46 +02:00
dni ⚡ 61bf9a6ffb extension manager 2024-10-17 10:36:45 +02:00
dni ⚡ 56e348a786 last fixes 2024-10-17 10:36:44 +02:00
dni ⚡ 225181c969 big fx part2 2024-10-17 10:36:42 +02:00
dni ⚡ 03f2417d4d fix part1 2024-10-17 10:36:41 +02:00
Vlad Stananddni ⚡ bc0547ef07 chore: code clean-up 2024-10-17 10:36:38 +02:00
dni ⚡ 91a95b60d7 remove where from argument 2024-10-17 10:36:37 +02:00
dni ⚡ 5aa88c56b7 fixup! 2024-10-17 10:36:36 +02:00
dni ⚡ d59b3b6bfb more docs 2024-10-17 10:36:35 +02:00
dni ⚡ bdeac94d99 fixup! 2024-10-17 10:36:33 +02:00
dni ⚡ 2764fefb3d fixup! 2024-10-17 10:36:32 +02:00
dni ⚡ 42c2e5b267 fixup! 2024-10-17 10:36:31 +02:00
dni ⚡ 881329eaa6 fixup! 2024-10-17 10:36:29 +02:00
dni ⚡ 3d2ed02fb1 feat: add shortcuts for insert_query and update_query into Database
example: await db.insert("table_name", base_model)
2024-10-17 10:36:28 +02:00
Vlad StanandGitHub ae4eda04ba test: more payment tests (#2738)
* test: pay_invoice
2024-10-17 10:27:36 +02:00
Vlad StanandGitHub 13f2dd732f fix: await retry (#2739) 2024-10-16 11:09:45 +02:00
Pavol RusnakandGitHub 80ec9e1307 update starlette and fastapi (#2737) 2024-10-16 07:00:12 +02:00
Vlad StanandGitHub 3b503eaa8a feat: add server status endpoin (#2736) 2024-10-15 14:53:49 +03:00
dni ⚡andGitHub ecc62b0011 feat: explicitly export for extensions from __init__.py (#2669)
* feat: explicitly export for extensions from `__init__.py`

makes it clear what extensions are expected to use and also makes future
changes to the structure of core safer because extension can just depend
on ```from lnbits import require_admin_key``` for example
2024-10-14 10:35:41 +03:00
b83c2e9368 docs: fix installation guide (#2734)
Co-authored-by: Gálli Zoltán <gallizoltan@gmail.com>
2024-10-10 21:22:15 +02:00
37 changed files with 1282 additions and 706 deletions
+3 -3
View File
@@ -375,7 +375,7 @@ Install Apache2 and enable Apache2 mods:
```sh
apt-get install apache2 certbot
a2enmod headers ssl proxy proxy-http
a2enmod headers ssl proxy proxy_http
```
Create a SSL certificate with LetsEncrypt:
@@ -414,7 +414,7 @@ EOF
Restart Apache2:
```sh
service restart apache2
service apache2 restart
```
## Running behind an Nginx reverse proxy over HTTPS
@@ -468,7 +468,7 @@ EOF
Restart nginx:
```sh
service restart nginx
service nginx restart
```
## Using https without reverse proxy
+24
View File
@@ -0,0 +1,24 @@
from .core.services import create_invoice, pay_invoice
from .decorators import (
check_admin,
check_super_user,
check_user_exists,
require_admin_key,
require_invoice_key,
)
from .exceptions import InvoiceError, PaymentError
__all__ = [
# decorators
"require_admin_key",
"require_invoice_key",
"check_admin",
"check_super_user",
"check_user_exists",
# services
"pay_invoice",
"create_invoice",
# exceptions
"PaymentError",
"InvoiceError",
]
+2 -2
View File
@@ -17,7 +17,7 @@ from slowapi.util import get_remote_address
from starlette.middleware.sessions import SessionMiddleware
from lnbits.core.crud import (
get_dbversions,
get_db_version,
get_installed_extensions,
update_installed_extension_state,
)
@@ -313,7 +313,7 @@ async def restore_installed_extension(app: FastAPI, ext: InstallableExtension):
extension = Extension.from_installable_ext(ext)
register_ext_routes(app, extension)
current_version = (await get_dbversions()).get(ext.id, 0)
current_version = await get_db_version(ext.id)
await migrate_extension_database(ext, current_version)
# mount routes for the new version
+8 -6
View File
@@ -17,12 +17,13 @@ from lnbits.core.crud import (
delete_unused_wallets,
delete_wallet_by_id,
delete_wallet_payment,
get_dbversions,
get_db_versions,
get_installed_extension,
get_installed_extensions,
get_payment,
get_payments,
remove_deleted_wallets,
update_payment_status,
update_payment,
)
from lnbits.core.extensions.models import (
CreateExtension,
@@ -122,7 +123,7 @@ def database_migrate():
async def db_versions():
"""Show current database versions"""
async with core_db.connect() as conn:
click.echo(await get_dbversions(conn))
click.echo(await get_db_versions(conn))
@db.command("cleanup-wallets")
@@ -172,9 +173,10 @@ async def database_delete_wallet_payment(wallet: str, checking_id: str):
async def database_revert_payment(checking_id: str):
"""Mark payment as pending"""
async with core_db.connect() as conn:
await update_payment_status(
status=PaymentState.PENDING, checking_id=checking_id, conn=conn
)
payment = await get_payment(checking_id=checking_id, conn=conn)
payment.status = PaymentState.PENDING
await update_payment(payment, conn=conn)
click.echo(f"Payment '{checking_id}' marked as pending.")
@db.command("cleanup-accounts")
+73 -128
View File
@@ -1,7 +1,7 @@
import json
from datetime import datetime, timezone
from time import time
from typing import Literal, Optional, Union
from typing import Literal, Optional
from uuid import uuid4
import shortuuid
@@ -25,6 +25,7 @@ from .models import (
AccountFilters,
AccountOverview,
CreatePayment,
DbVersion,
Payment,
PaymentFilters,
PaymentHistoryPoint,
@@ -35,6 +36,10 @@ from .models import (
)
def update_payment_extra():
pass
async def create_account(
account: Optional[Account] = None,
conn: Optional[Connection] = None,
@@ -161,15 +166,16 @@ async def get_account_by_username_or_email(
)
async def get_user(
account_or_id: Union[Account, str], conn: Optional[Connection] = None
async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[User]:
account = await get_account(user_id, conn)
if not account:
return None
return await get_user_from_account(account, conn)
async def get_user_from_account(
account: Account, conn: Optional[Connection] = None
) -> Optional[User]:
if isinstance(account_or_id, str):
account = await get_account(account_or_id, conn)
if not account:
return None
else:
account = account_or_id
extensions = await get_user_active_extensions_ids(account.id, conn)
wallets = await get_wallets(account.id, False, conn=conn)
return User(
@@ -304,7 +310,7 @@ async def create_user_extension(
async def update_user_extension(
user_extension: UserExtension, conn: Optional[Connection] = None
) -> None:
where = """extension = :extension AND "user" = :user"""
where = """WHERE extension = :extension AND "user" = :user"""
await (conn or db).update("extensions", user_extension, where)
@@ -476,6 +482,14 @@ async def get_total_balance(conn: Optional[Connection] = None):
# ---------------
async def get_payment(checking_id: str, conn: Optional[Connection] = None) -> Payment:
return await (conn or db).fetchone(
"SELECT * FROM apipayments WHERE checking_id = :checking_id",
{"checking_id": checking_id},
Payment,
)
async def get_standalone_payment(
checking_id_or_hash: str,
conn: Optional[Connection] = None,
@@ -677,118 +691,45 @@ async def create_payment(
previous_payment = await get_standalone_payment(checking_id, conn=conn)
assert previous_payment is None, "Payment already exists"
expiry_ph = db.timestamp_placeholder("expiry")
await (conn or db).execute(
f"""
INSERT INTO apipayments
(wallet_id, checking_id, bolt11, payment_hash, preimage,
amount, status, memo, fee, extra, webhook, expiry)
VALUES (:wallet_id, :checking_id, :bolt11, :hash, :preimage,
:amount, :status, :memo, :fee, :extra, :webhook, {expiry_ph})
""",
{
"wallet_id": data.wallet_id,
"checking_id": checking_id,
"bolt11": data.payment_request,
"hash": data.payment_hash,
"preimage": data.preimage,
"amount": data.amount,
"status": status.value,
"memo": data.memo,
"fee": data.fee,
"extra": (
json.dumps(data.extra)
if data.extra and data.extra != {} and isinstance(data.extra, dict)
else None
),
"webhook": data.webhook,
"expiry": data.expiry if data.expiry else None,
},
payment = Payment(
checking_id=checking_id,
status=status,
wallet_id=data.wallet_id,
payment_hash=data.payment_hash,
bolt11=data.bolt11,
amount=data.amount_msat,
memo=data.memo,
preimage=data.preimage,
expiry=data.expiry,
webhook=data.webhook,
fee=data.fee,
extra=data.extra or {},
)
new_payment = await get_wallet_payment(data.wallet_id, data.payment_hash, conn=conn)
assert new_payment, "Newly created payment couldn't be retrieved"
await (conn or db).insert("apipayments", payment)
return new_payment
return payment
async def update_payment_status(
checking_id: str, status: PaymentState, conn: Optional[Connection] = None
async def update_payment_checking_id(
checking_id: str, new_checking_id: str, conn: Optional[Connection] = None
) -> None:
await (conn or db).execute(
"UPDATE apipayments SET status = :status WHERE checking_id = :checking_id",
{"status": status.value, "checking_id": checking_id},
"UPDATE apipayments SET checking_id = :new_id WHERE checking_id = :old_id",
{"new_id": new_checking_id, "old_id": checking_id},
)
async def update_payment_details(
checking_id: str,
status: Optional[PaymentState] = None,
fee: Optional[int] = None,
preimage: Optional[str] = None,
async def update_payment(
payment: Payment,
new_checking_id: Optional[str] = None,
conn: Optional[Connection] = None,
) -> None:
set_variables: dict = {
"checking_id": checking_id,
"new_checking_id": new_checking_id,
"status": status.value if status else None,
"fee": fee,
"preimage": preimage,
}
set_clause: list[str] = []
if new_checking_id is not None:
set_clause.append("checking_id = :checking_id")
if status is not None:
set_clause.append("status = :status")
if fee is not None:
set_clause.append("fee = :fee")
if preimage is not None:
set_clause.append("preimage = :preimage")
await (conn or db).execute(
f"""
UPDATE apipayments SET {', '.join(set_clause)}
WHERE checking_id = :checking_id
""",
set_variables,
)
# TODO: should not be needed use update_payment instead
async def update_payment_extra(
payment_hash: str,
extra: dict,
outgoing: bool = False,
conn: Optional[Connection] = None,
) -> None:
"""
Only update the `extra` field for the payment.
Old values in the `extra` JSON object will be kept
unless the new `extra` overwrites them.
"""
amount_clause = "AND amount < 0" if outgoing else "AND amount > 0"
row: dict = await (conn or db).fetchone(
f"""
SELECT payment_hash, extra from apipayments
WHERE payment_hash = :hash {amount_clause}
""",
{"hash": payment_hash},
)
if not row:
return
db_extra = json.loads(row["extra"] if row["extra"] else "{}")
db_extra.update(extra)
await (conn or db).execute(
f"""
UPDATE apipayments SET extra = :extra WHERE payment_hash = :hash {amount_clause}
""",
{"extra": json.dumps(db_extra), "hash": payment_hash},
await (conn or db).update(
"apipayments", payment, "WHERE checking_id = :checking_id"
)
if new_checking_id and new_checking_id != payment.checking_id:
await update_payment_checking_id(payment.checking_id, new_checking_id, conn)
DateTrunc = Literal["hour", "day", "month"]
@@ -869,40 +810,38 @@ async def delete_wallet_payment(
async def check_internal(
payment_hash: str, conn: Optional[Connection] = None
) -> Optional[str]:
) -> Optional[Payment]:
"""
Returns the checking_id of the internal payment if it exists,
otherwise None
"""
row: dict = await (conn or db).fetchone(
return await (conn or db).fetchone(
f"""
SELECT checking_id FROM apipayments
SELECT * FROM apipayments
WHERE payment_hash = :hash AND status = '{PaymentState.PENDING}' AND amount > 0
""",
{"hash": payment_hash},
Payment,
)
if not row:
return None
else:
return row["checking_id"]
async def check_internal_status(
async def is_internal_status_success(
payment_hash: str, conn: Optional[Connection] = None
) -> bool:
"""
Returns True if the internal payment was successful
Returns True if the internal payment was found and is successful,
"""
row: dict = await (conn or db).fetchone(
payment = await (conn or db).fetchone(
"""
SELECT status FROM apipayments
SELECT * FROM apipayments
WHERE payment_hash = :payment_hash AND amount > 0
""",
{"payment_hash": payment_hash},
Payment,
)
if not row:
return True
return row["status"] == PaymentState.SUCCESS.value
if not payment:
return False
return payment.status == PaymentState.SUCCESS.value
async def mark_webhook_sent(payment_hash: str, status: int) -> None:
@@ -982,12 +921,18 @@ async def create_admin_settings(super_user: str, new_settings: dict):
# db versions
# --------------
async def get_dbversions(conn: Optional[Connection] = None) -> dict:
result = await (conn or db).execute("SELECT db, version FROM dbversions")
_dict = {}
for row in result.mappings().all():
_dict[row["db"]] = row["version"]
return _dict
async def get_db_version(
ext_id: str, conn: Optional[Connection] = None
) -> Optional[DbVersion]:
return await (conn or db).fetchone(
"SELECT * FROM dbversions WHERE db = :ext_id",
{"ext_id": ext_id},
model=DbVersion,
)
async def get_db_versions(conn: Optional[Connection] = None) -> list[DbVersion]:
return await (conn or db).fetchall("SELECT * FROM dbversions", model=DbVersion)
async def update_migration_version(conn, db_name, version):
+2 -2
View File
@@ -7,7 +7,7 @@ from lnbits.core import core_app_extra
from lnbits.core.crud import (
create_installed_extension,
delete_installed_extension,
get_dbversions,
get_db_version,
get_installed_extension,
update_installed_extension_state,
)
@@ -28,7 +28,7 @@ async def install_extension(ext_info: InstallableExtension) -> Extension:
ext_info.extract_archive()
db_version = (await get_dbversions()).get(ext_id, 0)
db_version = await get_db_version(ext_id)
await migrate_extension_database(ext_info, db_version)
await create_installed_extension(ext_info)
+20 -8
View File
@@ -1,6 +1,6 @@
import importlib
import re
from typing import Any
from typing import Any, Optional
from urllib.parse import urlparse
from uuid import UUID
@@ -8,17 +8,20 @@ from loguru import logger
from lnbits.core import migrations as core_migrations
from lnbits.core.crud import (
get_dbversions,
get_db_versions,
get_installed_extensions,
update_migration_version,
)
from lnbits.core.db import db as core_db
from lnbits.core.extensions.models import InstallableExtension
from lnbits.core.models import DbVersion
from lnbits.db import COCKROACH, POSTGRES, SQLITE, Connection
from lnbits.settings import settings
async def migrate_extension_database(ext: InstallableExtension, current_version: int):
async def migrate_extension_database(
ext: InstallableExtension, current_version: Optional[DbVersion] = None
):
try:
ext_migrations = importlib.import_module(f"{ext.module_name}.migrations")
@@ -32,14 +35,17 @@ async def migrate_extension_database(ext: InstallableExtension, current_version:
async def run_migration(
db: Connection, migrations_module: Any, db_name: str, current_version: int
db: Connection,
migrations_module: Any,
db_name: str,
current_version: Optional[DbVersion] = None,
):
matcher = re.compile(r"^m(\d\d\d)_")
for key, migrate in migrations_module.__dict__.items():
match = matcher.match(key)
if match:
version = int(match.group(1))
if version > current_version:
if not current_version or version > current_version.version:
logger.debug(f"running migration {db_name}.{version}")
print(f"running migration {db_name}.{version}")
await migrate(db)
@@ -71,7 +77,6 @@ async def load_disabled_extension_list() -> None:
async def migrate_databases():
"""Creates the necessary databases if they don't exist already; or migrates them."""
current_versions = await get_dbversions()
async with core_db.connect() as conn:
exists = False
if conn.type == SQLITE:
@@ -87,7 +92,11 @@ async def migrate_databases():
if not exists:
await core_migrations.m000_create_migrations_table(conn)
core_version = current_versions.get("core", 0)
current_versions = await get_db_versions(conn)
core_version = next(
(v for v in current_versions if v.db == "core"),
DbVersion(db="core", version=0),
)
await run_migration(conn, core_migrations, "core", core_version)
# here is the first place we can be sure that the
@@ -95,7 +104,10 @@ async def migrate_databases():
await load_disabled_extension_list()
for ext in await get_installed_extensions():
current_version = current_versions.get(ext.id)
current_version = next(
(v for v in current_versions if v.db == ext.id),
DbVersion(db=ext.id, version=0),
)
if current_version is None:
logger.warning(
f"Extension {ext.id} has no migration version. This should not happen."
+43 -8
View File
@@ -1,3 +1,4 @@
import json
from time import time
from loguru import logger
@@ -99,9 +100,8 @@ async def m002_add_fields_to_apipayments(db):
await db.execute("ALTER TABLE apipayments ADD COLUMN bolt11 TEXT")
await db.execute("ALTER TABLE apipayments ADD COLUMN extra TEXT")
import json
rows = await db.fetchall("SELECT * FROM apipayments")
result = await db.execute("SELECT * FROM apipayments")
rows = result.mappings().all()
for row in rows:
if not row["memo"] or not row["memo"].startswith("#"):
continue
@@ -211,7 +211,7 @@ async def m007_set_invoice_expiries(db):
Precomputes invoice expiry for existing pending incoming payments.
"""
try:
rows = await db.fetchall(
result = await db.execute(
f"""
SELECT bolt11, checking_id
FROM apipayments
@@ -222,6 +222,7 @@ async def m007_set_invoice_expiries(db):
AND time < {db.timestamp_now}
"""
)
rows = result.mappings().all()
if len(rows):
logger.info(f"Migration: Checking expiry of {len(rows)} invoices")
for i, (
@@ -339,7 +340,7 @@ async def m014_set_deleted_wallets(db):
Sets deleted column to wallets.
"""
try:
rows = await db.fetchall(
result = await db.execute(
"""
SELECT *
FROM wallets
@@ -348,12 +349,13 @@ async def m014_set_deleted_wallets(db):
AND inkey LIKE 'del:%'
"""
)
rows = result.mappings().all()
for row in rows:
try:
user = row[2].split(":")[1]
adminkey = row[3].split(":")[1]
inkey = row[4].split(":")[1]
user = row["user"].split(":")[1]
adminkey = row["adminkey"].split(":")[1]
inkey = row["inkey"].split(":")[1]
await db.execute(
"""
UPDATE wallets SET
@@ -562,6 +564,8 @@ async def m023_add_column_column_to_apipayments(db):
await db.execute("ALTER TABLE apipayments RENAME COLUMN wallet TO wallet_id")
await db.execute("ALTER TABLE accounts RENAME COLUMN pass TO password_hash")
await db.execute("CREATE INDEX by_hash ON apipayments (payment_hash)")
async def m024_drop_pending(db):
await db.execute("ALTER TABLE apipayments DROP COLUMN pending")
@@ -584,3 +588,34 @@ async def m025_refresh_view(db):
GROUP BY apipayments.wallet_id
"""
)
async def m026_update_payment_table(db):
await db.execute("ALTER TABLE apipayments ADD COLUMN tag TEXT")
await db.execute("ALTER TABLE apipayments ADD COLUMN extension TEXT")
await db.execute("ALTER TABLE apipayments ADD COLUMN created_at TIMESTAMP")
await db.execute("ALTER TABLE apipayments ADD COLUMN updated_at TIMESTAMP")
async def m027_update_apipayments_data(db):
result = await db.execute("SELECT * FROM apipayments")
payments = result.mappings().all()
for payment in payments:
tag = None
created_at = payment.get("time")
if payment.get("extra"):
extra = json.loads(payment.get("extra"))
tag = extra.get("tag")
tsph = db.timestamp_placeholder("created_at")
await db.execute(
f"""
UPDATE apipayments
SET tag = :tag, created_at = {tsph}, updated_at = {tsph}
WHERE checking_id = :checking_id
""",
{
"tag": tag,
"created_at": created_at,
"checking_id": payment.get("checking_id"),
},
)
+31 -26
View File
@@ -2,7 +2,6 @@ from __future__ import annotations
import hashlib
import hmac
import json
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum
@@ -27,15 +26,6 @@ from lnbits.wallets.base import (
)
def json_custom_serialization(_, o):
if isinstance(o, datetime):
return o.isoformat()
raise TypeError(f"Object is not JSON serializable: {o}")
json.JSONEncoder.default = json_custom_serialization # type: ignore[method-assign]
class BaseWallet(BaseModel):
id: str
name: str
@@ -267,34 +257,51 @@ class PaymentState(str, Enum):
return self.value
class PaymentExtra(BaseModel):
comment: Optional[str] = None
success_action: Optional[str] = None
lnurl_response: Optional[str] = None
class PayInvoice(BaseModel):
payment_request: str
description: Optional[str] = None
max_sat: Optional[int] = None
extra: Optional[dict] = {}
class CreatePayment(BaseModel):
wallet_id: str
payment_request: str
payment_hash: str
amount: int
bolt11: str
amount_msat: int
memo: str
extra: Optional[dict] = {}
preimage: Optional[str] = None
expiry: Optional[datetime] = None
extra: Optional[dict] = None
webhook: Optional[str] = None
fee: int = 0
class Payment(BaseModel):
status: str
checking_id: str
payment_hash: str
wallet_id: str
amount: int
fee: int
memo: Optional[str]
time: datetime
bolt11: str
expiry: Optional[datetime]
extra: Optional[dict]
webhook: Optional[str]
status: str = PaymentState.PENDING
memo: Optional[str] = None
expiry: Optional[datetime] = None
webhook: Optional[str] = None
webhook_status: Optional[int] = None
preimage: Optional[str] = "0" * 64
tag: Optional[str] = None
extension: Optional[str] = None
time: datetime = datetime.now(timezone.utc)
created_at: datetime = datetime.now(timezone.utc)
updated_at: datetime = datetime.now(timezone.utc)
extra: dict = {}
@property
def pending(self) -> bool:
@@ -308,12 +315,6 @@ class Payment(BaseModel):
def failed(self) -> bool:
return self.status == PaymentState.FAILED.value
@property
def tag(self) -> Optional[str]:
if self.extra is None:
return ""
return self.extra.get("tag")
@property
def msat(self) -> int:
return self.amount
@@ -438,7 +439,6 @@ class CreateInvoice(BaseModel):
def unit_is_from_allowed_currencies(cls, v):
if v != "sat" and v not in allowed_currencies():
raise ValueError("The provided unit is not supported")
return v
@@ -479,3 +479,8 @@ class BalanceDelta(BaseModel):
class SimpleStatus(BaseModel):
success: bool
message: str
class DbVersion(BaseModel):
db: str
version: int
+313 -328
View File
@@ -1,7 +1,6 @@
import asyncio
import json
import time
from datetime import datetime
from io import BytesIO
from pathlib import Path
from typing import Optional
@@ -9,8 +8,8 @@ from urllib.parse import parse_qs, urlparse
from uuid import UUID, uuid4
import httpx
from bolt11 import MilliSatoshi
from bolt11 import decode as bolt11_decode
from bolt11.types import Bolt11
from cryptography.hazmat.primitives import serialization
from fastapi import Depends, WebSocket
from loguru import logger
@@ -47,7 +46,6 @@ from lnbits.wallets.base import (
from .crud import (
check_internal,
check_internal_status,
create_account,
create_admin_settings,
create_payment,
@@ -60,12 +58,12 @@ from .crud import (
get_standalone_payment,
get_super_settings,
get_total_balance,
get_user,
get_user_from_account,
get_wallet,
get_wallet_payment,
is_internal_status_success,
update_admin_settings,
update_payment_details,
update_payment_status,
update_payment,
update_super_user,
update_user_extension,
)
@@ -84,22 +82,18 @@ from .models import (
async def calculate_fiat_amounts(
amount: float,
wallet_id: str,
wallet: Wallet,
currency: Optional[str] = None,
extra: Optional[dict] = None,
conn: Optional[Connection] = None,
) -> tuple[int, Optional[dict]]:
wallet = await get_wallet(wallet_id, conn=conn)
assert wallet, "invalid wallet_id"
) -> tuple[int, dict]:
wallet_currency = wallet.currency or settings.lnbits_default_accounting_currency
fiat_amounts: dict = extra or {}
if currency and currency != "sat":
amount_sat = await fiat_amount_as_satoshis(amount, currency)
extra = extra or {}
if currency != wallet_currency:
extra["fiat_currency"] = currency
extra["fiat_amount"] = round(amount, ndigits=3)
extra["fiat_rate"] = amount_sat / amount
fiat_amounts["fiat_currency"] = currency
fiat_amounts["fiat_amount"] = round(amount, ndigits=3)
fiat_amounts["fiat_rate"] = amount_sat / amount
else:
amount_sat = int(amount)
@@ -108,16 +102,15 @@ async def calculate_fiat_amounts(
fiat_amount = amount
else:
fiat_amount = await satoshis_amount_as_fiat(amount_sat, wallet_currency)
extra = extra or {}
extra["wallet_fiat_currency"] = wallet_currency
extra["wallet_fiat_amount"] = round(fiat_amount, ndigits=3)
extra["wallet_fiat_rate"] = amount_sat / fiat_amount
fiat_amounts["wallet_fiat_currency"] = wallet_currency
fiat_amounts["wallet_fiat_amount"] = round(fiat_amount, ndigits=3)
fiat_amounts["wallet_fiat_rate"] = amount_sat / fiat_amount
logger.debug(
f"Calculated fiat amounts {wallet.id=} {amount=} {currency=}: {extra=}"
f"Calculated fiat amounts {wallet.id=} {amount=} {currency=}: {fiat_amounts=}"
)
return amount_sat, extra
return amount_sat, fiat_amounts
async def create_invoice(
@@ -133,7 +126,7 @@ async def create_invoice(
webhook: Optional[str] = None,
internal: Optional[bool] = False,
conn: Optional[Connection] = None,
) -> tuple[str, str]:
) -> Payment:
if not amount > 0:
raise InvoiceError("Amountless invoices not supported.", status="failed")
@@ -147,7 +140,7 @@ async def create_invoice(
funding_source = fake_wallet if internal else get_funding_source()
amount_sat, extra = await calculate_fiat_amounts(
amount, wallet_id, currency=currency, extra=extra, conn=conn
amount, user_wallet, currency, extra
)
if settings.is_wallet_max_balance_exceeded(
@@ -180,22 +173,191 @@ async def create_invoice(
create_payment_model = CreatePayment(
wallet_id=wallet_id,
payment_request=payment_request,
bolt11=payment_request,
payment_hash=invoice.payment_hash,
amount=amount_sat * 1000,
amount_msat=amount_sat * 1000,
expiry=invoice.expiry_date,
memo=memo,
extra=extra,
webhook=webhook,
)
await create_payment(
payment = await create_payment(
checking_id=checking_id,
data=create_payment_model,
conn=conn,
)
return invoice.payment_hash, payment_request
return payment
async def _pay_internal_invoice(
wallet: Wallet,
create_payment_model: CreatePayment,
conn: Optional[Connection] = None,
) -> Optional[Payment]:
"""
Pay an internal payment.
returns None if the payment is not internal.
"""
# check_internal() returns the payment of the invoice we're waiting for
# (pending only)
internal_payment = await check_internal(
create_payment_model.payment_hash, conn=conn
)
if not internal_payment:
return None
# perform additional checks on the internal payment
# the payment hash is not enough to make sure that this is the same invoice
internal_invoice = await get_standalone_payment(
internal_payment.checking_id, incoming=True, conn=conn
)
if not internal_invoice:
raise PaymentError("Internal payment not found.", status="failed")
amount_msat = create_payment_model.amount_msat
if (
internal_invoice.amount != abs(amount_msat)
or internal_invoice.bolt11 != create_payment_model.bolt11.lower()
):
raise PaymentError("Invalid invoice. Bolt11 changed.", status="failed")
fee_reserve_total_msat = fee_reserve_total(abs(amount_msat), internal=True)
create_payment_model.fee = abs(fee_reserve_total_msat)
if wallet.balance_msat < abs(amount_msat) + fee_reserve_total_msat:
raise PaymentError("Insufficient balance.", status="failed")
internal_id = f"internal_{create_payment_model.payment_hash}"
logger.debug(f"creating temporary internal payment with id {internal_id}")
payment = await create_payment(
checking_id=internal_id,
data=create_payment_model,
status=PaymentState.SUCCESS,
conn=conn,
)
# mark the invoice from the other side as not pending anymore
# so the other side only has access to his new money when we are sure
# the payer has enough to deduct from
internal_payment.status = PaymentState.SUCCESS
await update_payment(internal_payment, conn=conn)
await send_payment_notification(wallet, payment)
# notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue
logger.debug(f"enqueuing internal invoice {internal_payment.checking_id}")
await internal_invoice_queue.put(internal_payment.checking_id)
return payment
async def _verify_external_payment(
payment: Payment, conn: Optional[Connection] = None
) -> Payment:
# fail on pending payments
if payment.pending:
raise PaymentError("Payment is still pending.", status="pending")
if payment.success:
raise PaymentError("Payment already paid.", status="success")
# payment failed
status = await payment.check_status()
if status.failed:
raise PaymentError(
"Payment is failed node, retrying is not possible.", status="failed"
)
if status.success:
# payment was successful on the fundingsource
payment.status = PaymentState.SUCCESS
await update_payment(payment, conn=conn)
raise PaymentError(
"Failed payment was already paid on the fundingsource.",
status="success",
)
# status.pending fall through and try again
return payment
async def _pay_external_invoice(
wallet: Wallet,
create_payment_model: CreatePayment,
conn: Optional[Connection] = None,
) -> Payment:
checking_id = create_payment_model.payment_hash
amount_msat = create_payment_model.amount_msat
fee_reserve_total_msat = fee_reserve_total(amount_msat, internal=False)
if wallet.balance_msat < abs(amount_msat) + fee_reserve_total_msat:
raise PaymentError(
f"You must reserve at least ({round(fee_reserve_total_msat/1000)}"
" sat) to cover potential routing fees.",
status="failed",
)
# check if there is already a payment with the same checking_id
old_payment = await get_standalone_payment(checking_id, conn=conn)
if old_payment:
return await _verify_external_payment(old_payment, conn)
create_payment_model.fee = -abs(fee_reserve_total_msat)
payment = await create_payment(
checking_id=checking_id,
data=create_payment_model,
conn=conn,
)
fee_reserve_msat = fee_reserve(amount_msat, internal=False)
service_fee_msat = service_fee(amount_msat, internal=False)
funding_source = get_funding_source()
logger.debug(f"fundingsource: sending payment {checking_id}")
payment_response: PaymentResponse = await funding_source.pay_invoice(
create_payment_model.bolt11, fee_reserve_msat
)
logger.debug(f"backend: pay_invoice finished {checking_id}, {payment_response}")
if payment_response.checking_id and payment_response.checking_id != checking_id:
logger.warning(
f"backend sent unexpected checking_id (expected: {checking_id} got:"
f" {payment_response.checking_id})"
)
if payment_response.checking_id and payment_response.ok is not False:
# payment.ok can be True (paid) or None (pending)!
logger.debug(f"updating payment {checking_id}")
payment.status = (
PaymentState.SUCCESS
if payment_response.ok is True
else PaymentState.PENDING
)
payment.fee = -(abs(payment_response.fee_msat or 0) + abs(service_fee_msat))
payment.preimage = payment_response.preimage
await update_payment(payment, payment_response.checking_id, conn=conn)
payment.checking_id = payment_response.checking_id
if payment.success:
await send_payment_notification(wallet, payment)
logger.success(f"payment successful {payment_response.checking_id}")
elif payment_response.checking_id is None and payment_response.ok is False:
# payment failed
logger.debug(f"payment failed {checking_id}, {payment_response.error_message}")
payment.status = PaymentState.FAILED
await update_payment(payment, conn=conn)
raise PaymentError(
f"Payment failed: {payment_response.error_message}"
or "Payment failed, but backend didn't give us an error message.",
status="failed",
)
else:
logger.warning(
"didn't receive checking_id from backend, payment may be stuck in"
f" database: {checking_id}"
)
return payment
async def pay_invoice(
@@ -205,18 +367,66 @@ async def pay_invoice(
max_sat: Optional[int] = None,
extra: Optional[dict] = None,
description: str = "",
tag: str = "",
conn: Optional[Connection] = None,
) -> str:
"""
Pay a Lightning invoice.
First, we create a temporary payment in the database with fees set to the reserve
fee. We then check whether the balance of the payer would go negative.
We then attempt to pay the invoice through the backend. If the payment is
successful, we update the payment in the database with the payment details.
If the payment is unsuccessful, we delete the temporary payment.
If the payment is still in flight, we hope that some other process
will regularly check for the payment.
"""
) -> Payment:
invoice = _validate_payment_request(payment_request, max_sat)
assert invoice.amount_msat
async with db.reuse_conn(conn) if conn else db.connect() as conn:
amount_msat = invoice.amount_msat
wallet = await _check_wallet_for_payment(wallet_id, tag, amount_msat, conn)
if await is_internal_status_success(invoice.payment_hash, conn):
raise PaymentError("Internal invoice already paid.", status="failed")
_, extra = await calculate_fiat_amounts(amount_msat / 1000, wallet, extra=extra)
create_payment_model = CreatePayment(
wallet_id=wallet_id,
bolt11=payment_request,
payment_hash=invoice.payment_hash,
amount_msat=-amount_msat,
expiry=invoice.expiry_date,
memo=description or invoice.description or "",
extra=extra,
)
payment = await _pay_invoice(wallet, create_payment_model, conn)
await _credit_service_fee_wallet(payment, conn)
return payment
async def _pay_invoice(wallet, create_payment_model, conn):
payment = await _pay_internal_invoice(wallet, create_payment_model, conn)
if not payment:
payment = await _pay_external_invoice(wallet, create_payment_model, conn)
return payment
async def _check_wallet_for_payment(
wallet_id: str,
tag: str,
amount_msat: int,
conn: Optional[Connection],
):
wallet = await get_wallet(wallet_id, conn=conn)
if not wallet:
raise PaymentError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
# check if the payment is made for an extension that the user disabled
status = await check_user_extension_access(wallet.user, tag)
if not status.success:
raise PaymentError(status.message)
await check_wallet_limits(wallet_id, amount_msat, conn)
return wallet
def _validate_payment_request(
payment_request: str, max_sat: Optional[int] = None
) -> Bolt11:
try:
invoice = bolt11_decode(payment_request)
except Exception as exc:
@@ -224,276 +434,65 @@ 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")
await check_wallet_limits(wallet_id, conn, invoice.amount_msat)
async with db.reuse_conn(conn) if conn else db.connect() as conn:
temp_id = invoice.payment_hash
internal_id = f"internal_{invoice.payment_hash}"
_, extra = await calculate_fiat_amounts(
invoice.amount_msat / 1000, wallet_id, extra=extra, conn=conn
)
create_payment_model = CreatePayment(
wallet_id=wallet_id,
payment_request=payment_request,
payment_hash=invoice.payment_hash,
amount=-invoice.amount_msat,
expiry=invoice.expiry_date,
memo=description or invoice.description or "",
extra=extra,
)
# we check if an internal invoice exists that has already been paid
# (not pending anymore)
if await check_internal_status(invoice.payment_hash, conn=conn):
raise PaymentError("Internal invoice already paid.", status="failed")
# check_internal() returns the checking_id of the invoice we're waiting for
# (pending only)
internal_checking_id = await check_internal(invoice.payment_hash, conn=conn)
if internal_checking_id:
# perform additional checks on the internal payment
# the payment hash is not enough to make sure that this is the same invoice
internal_invoice = await get_standalone_payment(
internal_checking_id, incoming=True, conn=conn
)
assert internal_invoice is not None
if (
internal_invoice.amount != invoice.amount_msat
or internal_invoice.bolt11 != payment_request.lower()
):
raise PaymentError("Invalid invoice.", status="failed")
logger.debug(f"creating temporary internal payment with id {internal_id}")
# create a new payment from this wallet
fee_reserve_total_msat = fee_reserve_total(
invoice.amount_msat, internal=True
)
create_payment_model.fee = abs(fee_reserve_total_msat)
new_payment = await create_payment(
checking_id=internal_id,
data=create_payment_model,
status=PaymentState.SUCCESS,
conn=conn,
)
else:
new_payment = await _create_external_payment(
temp_id=temp_id,
amount_msat=invoice.amount_msat,
data=create_payment_model,
conn=conn,
)
# do the balance check
wallet = await get_wallet(wallet_id, conn=conn)
assert wallet, "Wallet for balancecheck could not be fetched"
fee_reserve_total_msat = fee_reserve_total(invoice.amount_msat, internal=False)
_check_wallet_balance(wallet, fee_reserve_total_msat, internal_checking_id)
if extra and "tag" in extra:
# check if the payment is made for an extension that the user disabled
status = await check_user_extension_access(wallet.user, extra["tag"])
if not status.success:
raise PaymentError(status.message)
if internal_checking_id:
service_fee_msat = service_fee(invoice.amount_msat, internal=True)
logger.debug(f"marking temporary payment as not pending {internal_checking_id}")
# mark the invoice from the other side as not pending anymore
# so the other side only has access to his new money when we are sure
# the payer has enough to deduct from
async with db.connect() as conn:
await update_payment_status(
checking_id=internal_checking_id,
status=PaymentState.SUCCESS,
conn=conn,
)
await send_payment_notification(wallet, new_payment)
# notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue
logger.debug(f"enqueuing internal invoice {internal_checking_id}")
await internal_invoice_queue.put(internal_checking_id)
else:
fee_reserve_msat = fee_reserve(invoice.amount_msat, internal=False)
service_fee_msat = service_fee(invoice.amount_msat, internal=False)
logger.debug(f"backend: sending payment {temp_id}")
# actually pay the external invoice
funding_source = get_funding_source()
payment: PaymentResponse = await funding_source.pay_invoice(
payment_request, fee_reserve_msat
)
if payment.checking_id and payment.checking_id != temp_id:
logger.warning(
f"backend sent unexpected checking_id (expected: {temp_id} got:"
f" {payment.checking_id})"
)
logger.debug(f"backend: pay_invoice finished {temp_id}, {payment}")
if payment.checking_id and payment.ok is not False:
# payment.ok can be True (paid) or None (pending)!
logger.debug(f"updating payment {temp_id}")
async with db.connect() as conn:
await update_payment_details(
checking_id=temp_id,
status=(
PaymentState.SUCCESS
if payment.ok is True
else PaymentState.PENDING
),
fee=-(
abs(payment.fee_msat if payment.fee_msat else 0)
+ abs(service_fee_msat)
),
preimage=payment.preimage,
new_checking_id=payment.checking_id,
conn=conn,
)
wallet = await get_wallet(wallet_id, conn=conn)
updated = await get_wallet_payment(
wallet_id, payment.checking_id, conn=conn
)
if wallet and updated:
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:
# payment failed
logger.debug(f"payment failed {temp_id}, {payment.error_message}")
async with db.connect() as conn:
await update_payment_status(
checking_id=temp_id,
status=PaymentState.FAILED,
conn=conn,
)
raise PaymentError(
f"Payment failed: {payment.error_message}"
or "Payment failed, but backend didn't give us an error message.",
status="failed",
)
else:
logger.warning(
"didn't receive checking_id from backend, payment may be stuck in"
f" database: {temp_id}"
)
# credit service fee wallet
if settings.lnbits_service_fee_wallet and service_fee_msat:
create_payment_model = CreatePayment(
wallet_id=settings.lnbits_service_fee_wallet,
payment_request=payment_request,
payment_hash=invoice.payment_hash,
amount=abs(service_fee_msat),
memo="Service fee",
)
new_payment = await create_payment(
checking_id=f"service_fee_{temp_id}",
data=create_payment_model,
status=PaymentState.SUCCESS,
)
return invoice.payment_hash
return invoice
async def _create_external_payment(
temp_id: str,
amount_msat: MilliSatoshi,
data: CreatePayment,
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:
# fail on pending payments
if old_payment.pending:
raise PaymentError("Payment is still pending.", status="pending")
if old_payment.success:
raise PaymentError("Payment already paid.", status="success")
if old_payment.failed:
status = await old_payment.check_status()
if status.success:
# payment was successful on the fundingsource
await update_payment_status(
checking_id=temp_id, status=PaymentState.SUCCESS, conn=conn
)
raise PaymentError(
"Failed payment was already paid on the fundingsource.",
status="success",
)
if status.failed:
raise PaymentError(
"Payment is failed node, retrying is not possible.", status="failed"
)
# status.pending fall through and try again
return old_payment
logger.debug(f"creating temporary payment with id {temp_id}")
# create a temporary payment here so we can check if
# the balance is enough in the next step
try:
data.fee = -abs(fee_reserve_total_msat)
new_payment = await create_payment(
checking_id=temp_id,
data=data,
conn=conn,
)
return new_payment
except Exception as exc:
logger.error(f"could not create temporary payment: {exc}")
# happens if the same wallet tries to pay an invoice twice
raise PaymentError("Could not make payment", status="failed") from exc
def _check_wallet_balance(
wallet: Wallet,
fee_reserve_total_msat: int,
internal_checking_id: Optional[str] = None,
async def _credit_service_fee_wallet(
payment: Payment, conn: Optional[Connection] = None
):
if wallet.balance_msat < 0:
logger.debug("balance is too low, deleting temporary payment")
if not internal_checking_id and wallet.balance_msat > -fee_reserve_total_msat:
raise PaymentError(
f"You must reserve at least ({round(fee_reserve_total_msat/1000)}"
" sat) to cover potential routing fees.",
status="failed",
)
raise PaymentError("Insufficient balance.", status="failed")
service_fee_msat = service_fee(payment.amount, internal=payment.is_internal)
if not settings.lnbits_service_fee_wallet or not service_fee_msat:
return
create_payment_model = CreatePayment(
wallet_id=settings.lnbits_service_fee_wallet,
bolt11=payment.bolt11,
payment_hash=payment.payment_hash,
amount_msat=abs(service_fee_msat),
memo="Service fee",
)
await create_payment(
checking_id=f"service_fee_{payment.payment_hash}",
data=create_payment_model,
status=PaymentState.SUCCESS,
conn=conn,
)
async def check_wallet_limits(wallet_id, conn, amount_msat):
await check_time_limit_between_transactions(conn, wallet_id)
await check_wallet_daily_withdraw_limit(conn, wallet_id, amount_msat)
async def check_wallet_limits(
wallet_id: str, amount_msat: int, conn: Optional[Connection] = None
):
await check_time_limit_between_transactions(wallet_id, conn)
await check_wallet_daily_withdraw_limit(wallet_id, amount_msat, conn)
async def check_time_limit_between_transactions(conn, wallet_id):
async def check_time_limit_between_transactions(
wallet_id: str, conn: Optional[Connection] = None
):
limit = settings.lnbits_wallet_limit_secs_between_trans
if not limit or limit <= 0:
return
payments = await get_payments(
since=int(time.time()) - limit,
wallet_id=wallet_id,
limit=1,
conn=conn,
)
if len(payments) == 0:
return
raise PaymentError(
status="failed",
message=f"The time limit of {limit} seconds between payments has been reached.",
)
async def check_wallet_daily_withdraw_limit(conn, wallet_id, amount_msat):
async def check_wallet_daily_withdraw_limit(
wallet_id: str, amount_msat: int, conn: Optional[Connection] = None
):
limit = settings.lnbits_wallet_limit_daily_max_withdraw
if not limit:
return
@@ -682,6 +681,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:
@@ -701,38 +701,33 @@ def fee_reserve_total(amount_msat: int, internal: bool = False) -> int:
async def send_payment_notification(wallet: Wallet, payment: Payment):
await websocket_updater(
wallet.inkey,
json.dumps(
{
"wallet_balance": wallet.balance,
"payment": payment.dict(),
}
),
)
await websocket_updater(
payment.payment_hash, json.dumps({"pending": payment.pending})
await websocket_manager.send_data(payment.json(), wallet.inkey)
# json.dumps(
# {
# "wallet_balance": wallet.balance,
# "payment": payment,
# }
# ),
await websocket_manager.send_data(
json.dumps({"pending": payment.pending}), payment.payment_hash
)
async def update_wallet_balance(wallet_id: str, amount: int):
payment_hash, _ = await create_invoice(
wallet_id=wallet_id,
amount=amount,
memo="Admin top up",
internal=True,
)
async with db.connect() as conn:
checking_id = await check_internal(payment_hash, conn=conn)
assert checking_id, "newly created checking_id cannot be retrieved"
await update_payment_status(
checking_id=checking_id, status=PaymentState.SUCCESS, conn=conn
payment = await create_invoice(
wallet_id=wallet_id,
amount=amount,
memo="Admin top up",
internal=True,
conn=conn,
)
payment.status = PaymentState.SUCCESS
await update_payment(payment, conn=conn)
# notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue
await internal_invoice_queue.put(checking_id)
await internal_invoice_queue.put(payment.checking_id)
async def check_admin_settings():
@@ -813,22 +808,16 @@ def update_cached_settings(sets_dict: dict):
async def init_admin_settings(super_user: Optional[str] = None) -> SuperSettings:
async def new_account(account_id: str) -> Account:
now = datetime.now()
account = Account(
id=account_id,
extra=UserExtra(provider="env"),
created_at=now,
updated_at=now,
)
await create_account(account)
return account
account = None
if super_user:
account = await get_account(super_user)
if not account:
account = await new_account(super_user or uuid4().hex)
account_id = super_user or uuid4().hex
account = Account(
id=account_id,
extra=UserExtra(provider="env"),
)
await create_account(account)
await create_wallet(user_id=account.id)
editable_settings = EditableSettings.from_dict(settings.dict())
@@ -866,7 +855,7 @@ async def create_user_account(
user_ext = UserExtension(user=account.id, extension=ext_id, active=True)
await update_user_extension(user_ext)
user = await get_user(account)
user = await get_user_from_account(account)
assert user, "Cannot find user for account."
return user
@@ -893,8 +882,8 @@ class WebsocketConnectionManager:
websocket_manager = WebsocketConnectionManager()
async def websocket_updater(item_id, data):
return await websocket_manager.send_data(f"{data}", item_id)
async def websocket_updater(item_id: str, data: str):
return await websocket_manager.send_data(data, item_id)
async def switch_to_voidwallet() -> None:
@@ -924,12 +913,8 @@ async def update_pending_payments(wallet_id: str):
for payment in pending_payments:
status = await payment.check_status()
if status.failed:
await update_payment_status(
checking_id=payment.checking_id,
status=PaymentState.FAILED,
)
payment.status = PaymentState.FAILED
await update_payment(payment)
elif status.success:
await update_payment_status(
checking_id=payment.checking_id,
status=PaymentState.SUCCESS,
)
payment.status = PaymentState.SUCCESS
await update_payment(payment)
+4 -4
View File
@@ -621,8 +621,8 @@
<div v-else>
<q-responsive :ratio="1">
<qrcode-stream
@decode="decodeQR"
@init="onInitQR"
@detect="decodeQR"
@camera-on="onInitQR"
class="rounded-borders"
></qrcode-stream>
</q-responsive>
@@ -645,8 +645,8 @@
<q-card class="q-pa-lg q-pt-xl">
<div class="text-center q-mb-lg">
<qrcode-stream
@decode="decodeQR"
@init="onInitQR"
@detect="decodeQR"
@camera-on="onInitQR"
class="rounded-borders"
></qrcode-stream>
</div>
+29 -1
View File
@@ -3,6 +3,7 @@ import json
from http import HTTPStatus
from io import BytesIO
from time import time
from typing import Any
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
import httpx
@@ -14,6 +15,7 @@ from fastapi import (
from fastapi.exceptions import HTTPException
from fastapi.responses import StreamingResponse
from lnbits.core.crud import get_user
from lnbits.core.models import (
BaseWallet,
ConversionData,
@@ -36,6 +38,8 @@ from lnbits.utils.exchange_rates import (
get_fiat_rate_satoshis,
satoshis_amount_as_fiat,
)
from lnbits.wallets import get_funding_source
from lnbits.wallets.base import StatusResponse
from ..services import create_user_account, perform_lnurlauth
@@ -47,10 +51,34 @@ async def health() -> dict:
return {
"server_time": int(time()),
"up_time": int(time() - settings.server_startup_time),
"version": settings.version,
}
@api_router.get("/api/v1/status", status_code=HTTPStatus.OK)
async def health_check(wallet: WalletTypeInfo = Depends(require_invoice_key)) -> dict:
stat: dict[str, Any] = {
"server_time": int(time()),
"up_time": int(time() - settings.server_startup_time),
}
user = await get_user(wallet.wallet.user)
if not user:
return stat
stat["version"] = settings.version
if not user.admin:
return stat
funding_source = get_funding_source()
stat["funding_source"] = funding_source.__class__.__name__
status: StatusResponse = await funding_source.status()
stat["funding_source_error"] = status.error_message
stat["funding_source_balance_msat"] = status.balance_msat
return stat
@api_router.get(
"/api/v1/wallets",
name="Wallets",
+4 -4
View File
@@ -29,7 +29,7 @@ from ..crud import (
get_account_by_pubkey,
get_account_by_username,
get_account_by_username_or_email,
get_user,
get_user_from_account,
update_account,
)
from ..models import (
@@ -199,7 +199,7 @@ async def update_pubkey(
account.pubkey = normalize_public_key(data.pubkey)
await update_account(account)
return await get_user(account)
return await get_user_from_account(account)
@auth_router.put("/password")
@@ -228,7 +228,7 @@ async def update_password(
account.username = data.username
account.hash_password(data.password)
await update_account(account)
_user = await get_user(account)
_user = await get_user_from_account(account)
if not _user:
raise HTTPException(HTTPStatus.NOT_FOUND, "User not found.")
return _user
@@ -306,7 +306,7 @@ async def update(
account.extra = data.extra
await update_account(account)
return await get_user(account)
return await get_user_from_account(account)
@auth_router.put("/first_install")
+9 -5
View File
@@ -1,3 +1,5 @@
import sys
import traceback
from http import HTTPStatus
from bolt11 import decode as bolt11_decode
@@ -40,7 +42,7 @@ from ..crud import (
create_user_extension,
delete_dbversion,
drop_extension_db,
get_dbversions,
get_db_version,
get_installed_extension,
get_installed_extensions,
get_user_extension,
@@ -84,6 +86,8 @@ async def api_install_extension(data: CreateExtension):
except Exception as exc:
logger.warning(exc)
etype, _, tb = sys.exc_info()
traceback.print_exception(etype, exc, tb)
ext_info.clean_extension_files()
detail = (
str(exc)
@@ -430,7 +434,7 @@ async def get_pay_to_enable_invoice(
),
)
payment_hash, payment_request = await create_invoice(
payment = await create_invoice(
wallet_id=ext.meta.pay_to_enable.wallet,
amount=data.amount,
memo=f"Enable '{ext.name}' extension.",
@@ -441,10 +445,10 @@ async def get_pay_to_enable_invoice(
user_ext = UserExtension(user=user.id, extension=ext_id, active=False)
await create_user_extension(user_ext)
user_ext_info = user_ext.extra if user_ext.extra else UserExtensionInfo()
user_ext_info.payment_hash_to_enable = payment_hash
user_ext_info.payment_hash_to_enable = payment.payment_hash
user_ext.extra = user_ext_info
await update_user_extension(user_ext)
return {"payment_hash": payment_hash, "payment_request": payment_request}
return {"payment_hash": payment.payment_hash, "payment_request": payment.bolt11}
@extension_router.get(
@@ -474,7 +478,7 @@ async def get_extension_release(org: str, repo: str, tag_name: str):
)
async def delete_extension_db(ext_id: str):
try:
db_version = (await get_dbversions()).get(ext_id, None)
db_version = await get_db_version(ext_id)
if not db_version:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
+6 -3
View File
@@ -23,7 +23,7 @@ from lnbits.wallets import get_funding_source
from ...utils.exchange_rates import allowed_currencies, currencies
from ..crud import (
create_wallet,
get_dbversions,
get_db_versions,
get_installed_extensions,
get_user,
get_wallet,
@@ -104,7 +104,8 @@ async def extensions(request: Request, user: User = Depends(check_user_exists)):
all_ext_ids = [ext.code for ext in Extension.get_valid_extensions()]
inactive_extensions = [e.id for e in await get_installed_extensions(active=False)]
db_version = await get_dbversions()
db_versions = await get_db_versions()
extensions = [
{
"id": ext.id,
@@ -115,7 +116,9 @@ async def extensions(request: Request, user: User = Depends(check_user_exists)):
"isFeatured": ext.meta.featured if ext.meta else False,
"dependencies": ext.meta.dependencies if ext.meta else "",
"isInstalled": ext.id in installed_exts_ids,
"hasDatabaseTables": ext.id in db_version,
"hasDatabaseTables": next(
(True for version in db_versions if version.db == ext.id), False
),
"isAvailable": ext.id in all_ext_ids,
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
"isActive": ext.id not in inactive_extensions,
+33 -66
View File
@@ -3,13 +3,12 @@ import json
import uuid
from http import HTTPStatus
from math import ceil
from typing import List, Optional, Union
from typing import List, Optional
from urllib.parse import urlparse
import httpx
from fastapi import (
APIRouter,
Body,
Depends,
Header,
HTTPException,
@@ -21,7 +20,6 @@ from loguru import logger
from sse_starlette.sse import EventSourceResponse
from lnbits import bolt11
from lnbits.core.db import db
from lnbits.core.models import (
CreateInvoice,
CreateLnurl,
@@ -121,7 +119,7 @@ async def api_payments_paginated(
return page
async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
async def _api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
description_hash = b""
unhashed_description = b""
memo = data.memo or settings.lnbits_site_title
@@ -145,60 +143,42 @@ async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
# do not save memo if description_hash or unhashed_description is set
memo = ""
async with db.connect() as conn:
payment_hash, payment_request = await create_invoice(
wallet_id=wallet.id,
amount=data.amount,
memo=memo,
currency=data.unit,
description_hash=description_hash,
unhashed_description=unhashed_description,
expiry=data.expiry,
extra=data.extra,
webhook=data.webhook,
internal=data.internal,
conn=conn,
)
# NOTE: we get the checking_id with a seperate query because create_invoice
# does not return it and it would be a big hustle to change its return type
# (used across extensions)
payment_db = await get_standalone_payment(payment_hash, conn=conn)
assert payment_db is not None, "payment not found"
checking_id = payment_db.checking_id
payment = await create_invoice(
wallet_id=wallet.id,
amount=data.amount,
memo=memo,
currency=data.unit,
description_hash=description_hash,
unhashed_description=unhashed_description,
expiry=data.expiry,
extra=data.extra,
webhook=data.webhook,
internal=data.internal,
)
invoice = bolt11.decode(payment_request)
lnurl_response: Union[None, bool, str] = None
# lnurl_response is not saved in the database
if data.lnurl_callback:
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
try:
r = await client.get(
data.lnurl_callback,
params={
"pr": payment_request,
},
params={"pr": payment.bolt11},
timeout=10,
)
if r.is_error:
lnurl_response = r.text
payment.extra["lnurl_response"] = r.text
else:
resp = json.loads(r.text)
if resp["status"] != "OK":
lnurl_response = resp["reason"]
payment.extra["lnurl_response"] = resp["reason"]
else:
lnurl_response = True
payment.extra["lnurl_response"] = True
except (httpx.ConnectError, httpx.RequestError) as ex:
logger.error(ex)
lnurl_response = False
payment.extra["lnurl_response"] = False
return {
"payment_hash": invoice.payment_hash,
"payment_request": payment_request,
"lnurl_response": lnurl_response,
# maintain backwards compatibility with API clients:
"checking_id": checking_id,
}
return payment
@payment_router.post(
@@ -220,30 +200,25 @@ async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
},
)
async def api_payments_create(
invoice_data: CreateInvoice,
wallet: WalletTypeInfo = Depends(require_invoice_key),
invoice_data: CreateInvoice = Body(...),
):
) -> Payment:
if invoice_data.out is True and wallet.key_type == KeyType.admin:
if not invoice_data.bolt11:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="BOLT11 string is invalid or not given",
detail="Missing BOLT11 invoice",
)
payment_hash = await pay_invoice(
payment = await pay_invoice(
wallet_id=wallet.wallet.id,
payment_request=invoice_data.bolt11,
extra=invoice_data.extra,
)
return {
"payment_hash": payment_hash,
# maintain backwards compatibility with API clients:
"checking_id": payment_hash,
}
return payment
elif not invoice_data.out:
# invoice key
return await api_payments_create_invoice(invoice_data, wallet.wallet)
return await _api_payments_create_invoice(invoice_data, wallet.wallet)
else:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
@@ -269,7 +244,7 @@ async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONRespo
@payment_router.post("/lnurl")
async def api_payments_pay_lnurl(
data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key)
):
) -> Payment:
domain = urlparse(data.callback).netloc
headers = {"User-Agent": settings.user_agent}
@@ -313,15 +288,12 @@ async def api_payments_pay_lnurl(
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=(
(
f"{domain} returned an invalid invoice. Expected"
f" {amount_msat} msat, got {invoice.amount_msat}."
),
f"{domain} returned an invalid invoice. Expected"
f" {amount_msat} msat, got {invoice.amount_msat}."
),
)
extra = {}
if params.get("successAction"):
extra["success_action"] = params["successAction"]
if data.comment:
@@ -330,19 +302,14 @@ async def api_payments_pay_lnurl(
extra["fiat_currency"] = data.unit
extra["fiat_amount"] = data.amount / 1000
assert data.description is not None, "description is required"
payment_hash = await pay_invoice(
payment = await pay_invoice(
wallet_id=wallet.wallet.id,
payment_request=params["pr"],
description=data.description,
extra=extra,
)
return {
"success_action": params.get("successAction"),
"payment_hash": payment_hash,
# maintain backwards compatibility with API clients:
"checking_id": payment_hash,
}
return payment
async def subscribe_wallet_invoices(request: Request, wallet: Wallet):
+5 -2
View File
@@ -175,7 +175,9 @@ class Connection(Compat):
return dict_to_model(row, model)
return row
async def update(self, table_name: str, model: BaseModel, where: str = "id = :id"):
async def update(
self, table_name: str, model: BaseModel, where: str = "WHERE id = :id"
):
await self.conn.execute(
text(update_query(table_name, model, where)), model_to_dict(model)
)
@@ -611,7 +613,7 @@ def model_to_dict(model: BaseModel) -> dict:
if isinstance(value, datetime.datetime):
_dict[key] = value.timestamp()
continue
if type(type_) is type(BaseModel):
if type(type_) is type(BaseModel) or type_ is dict:
_dict[key] = json.dumps(value)
continue
_dict[key] = value
@@ -652,6 +654,7 @@ def dict_to_model(_row: dict, model: type[TModel]) -> TModel:
if issubclass(type_, BaseModel) and value:
_dict[key] = dict_to_submodel(type_, value)
continue
# TODO: remove this when all sub models are migrated to Pydantic
if type_ is dict and value:
_dict[key] = json.loads(value)
continue
+2 -2
View File
@@ -14,8 +14,8 @@ from lnbits.core.crud import (
get_account,
get_account_by_email,
get_account_by_username,
get_user,
get_user_active_extensions_ids,
get_user_from_account,
get_wallet_for_key,
)
from lnbits.core.models import (
@@ -151,7 +151,7 @@ async def check_user_exists(
if not settings.is_user_allowed(account.id):
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not allowed.")
user = await get_user(account)
user = await get_user_from_account(account)
if not user:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not found.")
await _check_user_extension_access(user.id, r["path"])
+4
View File
@@ -70,6 +70,8 @@ def register_exception_handlers(app: FastAPI):
@app.exception_handler(AssertionError)
async def assert_error_handler(request: Request, exc: AssertionError):
etype, _, tb = sys.exc_info()
traceback.print_exception(etype, exc, tb)
logger.warning(f"AssertionError: {exc!s}")
return render_html_error(request, exc) or JSONResponse(
status_code=HTTPStatus.BAD_REQUEST,
@@ -78,6 +80,8 @@ def register_exception_handlers(app: FastAPI):
@app.exception_handler(ValueError)
async def value_error_handler(request: Request, exc: ValueError):
etype, _, tb = sys.exc_info()
traceback.print_exception(etype, exc, tb)
logger.warning(f"ValueError: {exc!s}")
return render_html_error(request, exc) or JSONResponse(
status_code=HTTPStatus.BAD_REQUEST,
File diff suppressed because one or more lines are too long
+2 -2
View File
File diff suppressed because one or more lines are too long
-4
View File
@@ -526,10 +526,6 @@ video {
word-break: break-word;
}
.q-card--dark, .q-date--dark {
box-shadow: none;
}
.q-card code {
overflow-wrap: break-word;
}
+1 -1
View File
@@ -278,7 +278,7 @@ window.LNbits = {
preimage: data.preimage,
payment_hash: data.payment_hash,
expiry: data.expiry,
extra: data.extra ? JSON.parse(data.extra) : {},
extra: data.extra ?? {},
wallet_id: data.wallet_id,
webhook: data.webhook,
webhook_status: data.webhook_status,
+3 -4
View File
@@ -409,7 +409,7 @@ window.app.component('lnbits-dynamic-fields', {
data() {
return {
formData: null,
rules: [val => !!val || 'Field is required'],
rules: [val => !!val || 'Field is required']
}
},
methods: {
@@ -428,7 +428,7 @@ window.app.component('lnbits-dynamic-fields', {
},
handleValueChanged() {
this.$emit('update:model-value', this.formData)
},
}
},
created() {
this.formData = this.buildData(this.options, this.modelValue)
@@ -450,7 +450,7 @@ window.app.component('lnbits-dynamic-chips', {
if (!this.chip) return
this.chips.push(this.chip)
this.chip = ''
this.modelValue = this.chips.join(',')
this.$emit('update:model-value', this.chips.join(','))
},
removeChip(index) {
this.chips.splice(index, 1)
@@ -464,7 +464,6 @@ window.app.component('lnbits-dynamic-chips', {
this.chips = [...this.modelValue]
}
}
})
window.app.component('lnbits-update-balance', {
+12 -9
View File
@@ -144,9 +144,11 @@ window.app = Vue.createApp({
)
.then(response => {
this.receive.status = 'success'
this.receive.paymentReq = response.data.payment_request
this.receive.paymentReq = response.data.bolt11
this.receive.paymentHash = response.data.payment_hash
// TODO: lnurl_callback and lnurl_response
// WITHDRAW
if (response.data.lnurl_response !== null) {
if (response.data.lnurl_response === false) {
response.data.lnurl_response = `Unable to connect`
@@ -257,7 +259,7 @@ window.app = Vue.createApp({
})
},
decodeQR: function (res) {
this.parse.data.request = res
this.parse.data.request = res[0].rawValue
this.decodeRequest()
this.parse.camera.show = false
},
@@ -393,12 +395,13 @@ window.app = Vue.createApp({
dismissPaymentMsg()
clearInterval(this.parse.paymentChecker)
// show lnurlpay success action
if (response.data.success_action) {
switch (response.data.success_action.tag) {
const extra = response.data.extra
if (extra.success_action) {
switch (extra.success_action.tag) {
case 'url':
Quasar.Notify.create({
message: `<a target="_blank" style="color: inherit" href="${response.data.success_action.url}">${response.data.success_action.url}</a>`,
caption: response.data.success_action.description,
message: `<a target="_blank" style="color: inherit" href="${extra.success_action.url}">${extra.success_action.url}</a>`,
caption: extra.success_action.description,
html: true,
type: 'positive',
timeout: 0,
@@ -407,7 +410,7 @@ window.app = Vue.createApp({
break
case 'message':
Quasar.Notify.create({
message: response.data.success_action.message,
message: extra.success_action.message,
type: 'positive',
timeout: 0,
closeBtn: true
@@ -418,14 +421,14 @@ window.app = Vue.createApp({
.getPayment(this.g.wallet, response.data.payment_hash)
.then(({data: payment}) =>
decryptLnurlPayAES(
response.data.success_action,
extra.success_action,
payment.preimage
)
)
.then(value => {
Quasar.Notify.create({
message: value,
caption: response.data.success_action.description,
caption: extra.success_action.description,
html: true,
type: 'positive',
timeout: 0,
File diff suppressed because one or more lines are too long
+12 -20
View File
@@ -20,8 +20,7 @@ from lnbits.core.crud import (
delete_webpush_subscriptions,
get_payments,
get_standalone_payment,
update_payment_details,
update_payment_status,
update_payment,
)
from lnbits.core.models import Payment, PaymentState
from lnbits.settings import settings
@@ -85,7 +84,7 @@ async def catch_everything_and_restart(
logger.error(traceback.format_exc())
logger.error("will restart the task in 5 seconds.")
await asyncio.sleep(5)
return catch_everything_and_restart(func, name)
return await catch_everything_and_restart(func, name)
invoice_listeners: Dict[str, asyncio.Queue] = {}
@@ -181,17 +180,14 @@ async def check_pending_payments():
status = await payment.check_status()
prefix = f"payment ({i+1} / {count})"
if status.failed:
await update_payment_status(
payment.checking_id, status=PaymentState.FAILED
)
payment.status = PaymentState.FAILED
await update_payment(payment)
logger.debug(f"{prefix} failed {payment.checking_id}")
elif status.success:
await update_payment_details(
checking_id=payment.checking_id,
fee=status.fee_msat,
preimage=status.preimage,
status=PaymentState.SUCCESS,
)
payment.fee = status.fee_msat or 0
payment.preimage = status.preimage
payment.status = PaymentState.SUCCESS
await update_payment(payment)
logger.debug(f"{prefix} success {payment.checking_id}")
else:
logger.debug(f"{prefix} pending {payment.checking_id}")
@@ -211,14 +207,10 @@ async def invoice_callback_dispatcher(checking_id: str, is_internal: bool = Fals
payment = await get_standalone_payment(checking_id, incoming=True)
if payment and payment.is_in:
status = await payment.check_status()
await update_payment_details(
checking_id=payment.checking_id,
fee=status.fee_msat,
preimage=status.preimage,
status=PaymentState.SUCCESS,
)
payment = await get_standalone_payment(checking_id, incoming=True)
assert payment, "updated payment not found"
payment.fee = status.fee_msat or 0
payment.preimage = status.preimage
payment.status = PaymentState.SUCCESS
await update_payment(payment)
internal = "internal" if is_internal else ""
logger.success(f"{internal} invoice {checking_id} settled")
for name, send_chan in invoice_listeners.items():
+10 -3
View File
@@ -393,7 +393,7 @@
></q-input>
<div v-else-if="o.type === 'chips'">
<lnbits-dynamic-chips
:model-value="formData[o.name]"
v-model="formData[o.name]"
@update:model-value="handleValueChanged"
></lnbits-dynamic-chips>
</div>
@@ -489,8 +489,15 @@
<template id="lnbits-qrcode">
<div class="qrcode__wrapper">
<qrcode-vue :value="value" size="350" class="rounded-borders"></qrcode-vue>
<img class="qrcode__image" :src="logo" alt="..." />
<qrcode-vue
:value="value"
level="Q"
render-as="svg"
margin="1"
size="350"
class="rounded-borders"
></qrcode-vue>
<img class="qrcode__image" :src="logo" alt="qrcode icon" />
</div>
</template>
+13 -11
View File
@@ -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
+4 -3
View File
@@ -1320,9 +1320,10 @@
}
},
"node_modules/vue-qrcode-reader": {
"version": "5.5.10",
"resolved": "https://registry.npmjs.org/vue-qrcode-reader/-/vue-qrcode-reader-5.5.10.tgz",
"integrity": "sha512-lj83FKqRyvo0VLMu49wrLsaHueonfXcwyX9r/GDw0y+myOY5xTfsl75hjBgmmByAxzFSlCPI+CGA9FxYVtRAFQ==",
"version": "5.5.11",
"resolved": "https://registry.npmjs.org/vue-qrcode-reader/-/vue-qrcode-reader-5.5.11.tgz",
"integrity": "sha512-Ec/bVML1jgxSX+usbgdcXGhOFEFo4EzApCO2CNT1YK0Dcb0Mp7ASygz78RJJs22SU2oI7vz9iJDyr4ucSDTvjQ==",
"license": "MIT",
"dependencies": {
"barcode-detector": "2.2.2",
"webrtc-adapter": "8.2.3"
Generated
+8 -8
View File
@@ -997,18 +997,18 @@ test = ["pytest (>=6)"]
[[package]]
name = "fastapi"
version = "0.113.0"
version = "0.115.2"
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
optional = false
python-versions = ">=3.8"
files = [
{file = "fastapi-0.113.0-py3-none-any.whl", hash = "sha256:c8d364485b6361fb643d53920a18d58a696e189abcb901ec03b487e35774c476"},
{file = "fastapi-0.113.0.tar.gz", hash = "sha256:b7cf9684dc154dfc93f8b718e5850577b529889096518df44defa41e73caf50f"},
{file = "fastapi-0.115.2-py3-none-any.whl", hash = "sha256:61704c71286579cc5a598763905928f24ee98bfcc07aabe84cfefb98812bbc86"},
{file = "fastapi-0.115.2.tar.gz", hash = "sha256:3995739e0b09fa12f984bce8fa9ae197b35d433750d3d312422d846e283697ee"},
]
[package.dependencies]
pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0"
starlette = ">=0.37.2,<0.39.0"
starlette = ">=0.37.2,<0.41.0"
typing-extensions = ">=4.8.0"
[package.extras]
@@ -2730,13 +2730,13 @@ uvicorn = "*"
[[package]]
name = "starlette"
version = "0.37.2"
version = "0.40.0"
description = "The little ASGI library that shines."
optional = false
python-versions = ">=3.8"
files = [
{file = "starlette-0.37.2-py3-none-any.whl", hash = "sha256:6fe59f29268538e5d0d182f2791a479a0c64638e6935d1c6989e63fb2699c6ee"},
{file = "starlette-0.37.2.tar.gz", hash = "sha256:9af890290133b79fc3db55474ade20f6220a364a0402e0b556e7cd5e1e093823"},
{file = "starlette-0.40.0-py3-none-any.whl", hash = "sha256:c494a22fae73805376ea6bf88439783ecfba9aac88a43911b48c653437e784c4"},
{file = "starlette-0.40.0.tar.gz", hash = "sha256:1a3139688fb298ce5e2d661d37046a66ad996ce94be4d4983be019a23a04ea35"},
]
[package.dependencies]
@@ -3193,4 +3193,4 @@ liquid = ["wallycore"]
[metadata]
lock-version = "2.0"
python-versions = "^3.12 | ^3.11 | ^3.10 | ^3.9"
content-hash = "f9f70d510dbe30a94f68fc12fcf1d11c6fb754367e9f36e6167be2ca19e28495"
content-hash = "f4847cf020ad21818fdad153d1845ed9c24d41d631aed1f6eec16158f6f295fb"
+2 -2
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "lnbits"
version = "1.0.0-rc3"
version = "1.0.0-rc5"
description = "LNbits, free and open-source Lightning wallet and accounts system."
authors = ["Alan Bits <alan@lnbits.com>"]
readme = "README.md"
@@ -16,7 +16,7 @@ python = "^3.12 | ^3.11 | ^3.10 | ^3.9"
bech32 = "1.2.0"
click = "8.1.7"
ecdsa = "0.19.0"
fastapi = "0.113.0"
fastapi = "0.115.2"
httpx = "0.27.0"
jinja2 = "3.1.4"
lnurl = "0.5.3"
+24 -14
View File
@@ -3,6 +3,8 @@ import asyncio
import uvloop
from lnbits.wallets.fake import FakeWallet
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
from time import time
@@ -20,12 +22,12 @@ from lnbits.core.crud import (
delete_account,
get_account,
get_account_by_username,
get_user,
update_payment_status,
get_payment,
get_user_from_account,
update_payment,
)
from lnbits.core.models import Account, CreateInvoice, PaymentState, User
from lnbits.core.services import create_user_account, update_wallet_balance
from lnbits.core.views.payment_api import api_payments_create_invoice
from lnbits.db import DB_TYPE, SQLITE, Database
from lnbits.settings import AuthMethods, Settings
from lnbits.settings import settings as lnbits_settings
@@ -43,18 +45,14 @@ def settings():
lnbits_settings.lnbits_extensions_default_install = []
lnbits_settings.lnbits_extensions_deactivate_all = True
return lnbits_settings
yield lnbits_settings
@pytest.fixture(autouse=True)
def run_before_and_after_tests(settings: Settings):
"""Fixture to execute asserts before and after a test is run"""
##### BEFORE TEST RUN #####
_settings_cleanup(settings)
yield # this is where the testing happens
##### AFTER TEST RUN #####
_settings_cleanup(settings)
@@ -161,7 +159,7 @@ def from_super_user(from_user: User, settings: Settings):
async def superuser(settings: Settings):
account = await get_account(settings.super_user)
assert account, "Superuser not found"
user = await get_user(account)
user = await get_user_from_account(account)
yield user
@@ -221,12 +219,21 @@ async def adminkey_headers_to(to_wallet):
@pytest_asyncio.fixture(scope="session")
async def invoice(to_wallet):
async def invoice(client, adminkey_headers_from):
data = await get_random_invoice_data()
invoice_data = CreateInvoice(**data)
invoice = await api_payments_create_invoice(invoice_data, to_wallet)
yield invoice
del invoice
response = await client.post(
"/api/v1/payments", headers=adminkey_headers_from, json=invoice_data.dict()
)
assert response.is_success
data = response.json()
assert data["checking_id"]
yield data["bolt11"]
@pytest_asyncio.fixture(scope="function")
async def external_funding_source():
yield FakeWallet()
@pytest_asyncio.fixture(scope="session")
@@ -250,7 +257,9 @@ async def fake_payments(client, adminkey_headers_from):
assert response.is_success
data = response.json()
assert data["checking_id"]
await update_payment_status(data["checking_id"], status=PaymentState.SUCCESS)
payment = await get_payment(data["checking_id"])
payment.status = PaymentState.SUCCESS
await update_payment(payment)
params = {"time[ge]": ts, "time[le]": time()}
return fake_data, params
@@ -265,3 +274,4 @@ def _settings_cleanup(settings: Settings):
settings.lnbits_reserve_fee_min = 2000
settings.lnbits_service_fee = 0
settings.lnbits_wallet_limit_daily_max_withdraw = 0
settings.lnbits_admin_extensions = []
+3 -2
View File
@@ -4,7 +4,7 @@ import hashlib
import pytest
from lnbits import bolt11
from lnbits.core.crud import get_standalone_payment, update_payment_details
from lnbits.core.crud import get_standalone_payment, update_payment
from lnbits.core.models import CreateInvoice, Payment, PaymentState
from lnbits.core.services import fee_reserve_total, get_balance_delta
from lnbits.tasks import create_task, wait_for_paid_invoices
@@ -303,7 +303,8 @@ async def test_receive_real_invoice_set_pending_and_check_state(
assert payment
# set the incoming invoice to pending
await update_payment_details(payment.checking_id, status=PaymentState.PENDING)
payment.status = PaymentState.PENDING
await update_payment(payment)
payment_pending = await get_standalone_payment(
invoice["payment_hash"], incoming=True
@@ -12,35 +12,35 @@ description = "test create invoice"
@pytest.mark.asyncio
async def test_create_invoice(from_wallet):
payment_hash, pr = await create_invoice(
payment = await create_invoice(
wallet_id=from_wallet.id,
amount=1000,
memo=description,
)
invoice = decode(pr)
assert invoice.payment_hash == payment_hash
invoice = decode(payment.bolt11)
assert invoice.payment_hash == payment.payment_hash
assert invoice.amount_msat == 1000000
assert invoice.description == description
funding_source = get_funding_source()
status = await funding_source.get_invoice_status(payment_hash)
status = await funding_source.get_invoice_status(payment.payment_hash)
assert isinstance(status, PaymentStatus)
assert status.pending
@pytest.mark.asyncio
async def test_create_internal_invoice(from_wallet):
payment_hash, pr = await create_invoice(
payment = await create_invoice(
wallet_id=from_wallet.id, amount=1000, memo=description, internal=True
)
invoice = decode(pr)
assert invoice.payment_hash == payment_hash
invoice = decode(payment.bolt11)
assert invoice.payment_hash == payment.payment_hash
assert invoice.amount_msat == 1000000
assert invoice.description == description
# Internal invoices are not on fundingsource. so we should get some kind of error
# that the invoice is not found, but we get status pending
funding_source = get_funding_source()
status = await funding_source.get_invoice_status(payment_hash)
status = await funding_source.get_invoice_status(payment.payment_hash)
assert isinstance(status, PaymentStatus)
assert status.pending
+1 -15
View File
@@ -1,8 +1,5 @@
import pytest
from lnbits.core.crud import (
get_standalone_payment,
)
from lnbits.core.services import (
PaymentError,
PaymentState,
@@ -14,27 +11,16 @@ description = "test pay invoice"
@pytest.mark.asyncio
async def test_services_pay_invoice(to_wallet, real_invoice):
payment_hash = await pay_invoice(
payment = await pay_invoice(
wallet_id=to_wallet.id,
payment_request=real_invoice.get("bolt11"),
description=description,
)
assert payment_hash
payment = await get_standalone_payment(payment_hash)
assert payment
assert not payment.status == PaymentState.SUCCESS
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
+562
View File
@@ -0,0 +1,562 @@
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 = await create_invoice(wallet_id=to_wallet.id, amount=31, memo="Bad Wallet")
bad_wallet_id = to_wallet.id[::-1]
with pytest.raises(
PaymentError, match=f"Could not fetch wallet '{bad_wallet_id}'."
):
await pay_invoice(
wallet_id=bad_wallet_id,
payment_request=payment.bolt11,
)
@pytest.mark.asyncio
async def test_payment_limit(to_wallet: Wallet):
payment = 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.bolt11,
)
@pytest.mark.asyncio
async def test_pay_twice(to_wallet: Wallet):
payment = await create_invoice(wallet_id=to_wallet.id, amount=3, memo="Twice")
await pay_invoice(
wallet_id=to_wallet.id,
payment_request=payment.bolt11,
)
with pytest.raises(PaymentError, match="Internal invoice already paid."):
await pay_invoice(
wallet_id=to_wallet.id,
payment_request=payment.bolt11,
)
@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 = await create_invoice(wallet_id=to_wallet.id, amount=21, memo="original")
invoice = bolt11_decode(payment.bolt11)
invoice.amount_msat = MilliSatoshi(12000)
payment_request = bolt11_encode(invoice)
with pytest.raises(PaymentError, match="Invalid invoice. Bolt11 changed."):
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, settings: Settings):
payment = await create_invoice(wallet_id=to_wallet.id, amount=3, memo="Allowed")
await pay_invoice(
wallet_id=to_wallet.id, payment_request=payment.bolt11, tag="lnurlp"
)
payment = 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.bolt11,
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 = await create_invoice(wallet_id=to_wallet.id, amount=123, memo=test_name)
await pay_invoice(
wallet_id=to_wallet.id, payment_request=payment.bolt11, 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.bolt11
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 = await pay_invoice(
wallet_id=from_wallet.id,
payment_request=external_invoice.payment_request,
)
_payment = await get_standalone_payment(payment.payment_hash)
assert _payment
assert _payment.status == PaymentState.PENDING.value
assert _payment.checking_id == payment.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 = await pay_invoice(
wallet_id=from_wallet.id,
payment_request=external_invoice.payment_request,
)
_payment = await get_standalone_payment(payment.payment_hash)
assert _payment
assert _payment.status == PaymentState.SUCCESS.value
assert _payment.checking_id == payment.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 = f"bad_{external_invoice.checking_id}"
preimage = "0000000000000000000000000000000000000000000000000000000000002108"
payment_reponse_success = PaymentResponse(
ok=True, checking_id=bad_checking_id, preimage=preimage
)
mocker.patch(
"lnbits.wallets.FakeWallet.pay_invoice",
AsyncMock(return_value=payment_reponse_success),
)
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 is None
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,
settings: Settings,
):
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 = await pay_invoice(
wallet_id=from_wallet.id,
payment_request=external_invoice.payment_request,
)
_payment = await get_standalone_payment(payment.payment_hash)
assert _payment
assert _payment.status == PaymentState.SUCCESS.value
assert _payment.checking_id == payment.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.payment_hash}"
)
assert service_fee_payment
assert service_fee_payment.status == PaymentState.SUCCESS.value
assert service_fee_payment.checking_id == f"service_fee_{payment.payment_hash}"
assert service_fee_payment.amount == 422_400
assert service_fee_payment.bolt11 == external_invoice.payment_request
assert service_fee_payment.preimage is None