Compare commits

..
74 Commits
Author SHA1 Message Date
dni ⚡ 63d316381e rc3 2024-10-10 09:14:35 +02:00
Tiago Vasconcelosanddni ⚡ 0e9b1b44b2 added thi one also DNI
remove it if not needed
2024-10-10 09:09:10 +02:00
Tiago Vasconcelosanddni ⚡ 9fa0ef97c1 attempt to make chip component in settings dynamic fields
it's not updating data
2024-10-10 09:09:09 +02:00
Tiago Vasconcelosanddni ⚡ 2e8128b85d screwed up and made more css pushes to this branch 2024-10-10 09:09:07 +02:00
Tiago Vasconcelosanddni ⚡ fd0fa75e11 remove shadow on cards in dark theme 2024-10-10 09:09:06 +02:00
dni ⚡ ff12a76b42 fix dynamic fields and ext db migration 2024-10-10 09:09:05 +02:00
Vlad Stananddni ⚡ 8a023471a9 fix: component name 2024-10-10 09:09:02 +02:00
Vlad Stananddni ⚡ 0b7c33c060 chore: code format 2024-10-10 09:09:01 +02:00
Vlad Stananddni ⚡ 4d2438cf49 fix: payment extra 2024-10-10 09:09:00 +02:00
Vlad Stananddni ⚡ f590efd719 fix: payment extra 2024-10-10 09:08:58 +02:00
Vlad Stananddni ⚡ d5ea1af518 chore: code format 2024-10-10 09:08:57 +02:00
Vlad Stananddni ⚡ f227be2ea5 fix: payment extra and payment hash 2024-10-10 09:08:56 +02:00
Vlad Stananddni ⚡ 3ccdc3e249 chore: log message fix 2024-10-10 09:08:54 +02:00
dni ⚡ 7ca0a35b3a vue3 @input migration 2024-10-10 09:08:53 +02:00
dni ⚡ 0286906bd9 extension toggle vue3 2024-10-10 09:08:52 +02:00
dni ⚡ 63b499d2c9 payments 2024-10-10 09:08:51 +02:00
Vlad Stananddni ⚡ 620325b399 fix: lint 2024-10-10 09:08:49 +02:00
Vlad Stananddni ⚡ 3cfbae3b28 chore: code format 2024-10-10 09:08:48 +02:00
Vlad Stananddni ⚡ a94f42368d feat: add JSON custom serializer 2024-10-10 09:08:47 +02:00
Vlad Stananddni ⚡ 74b24eb609 fix: cast balance to int 2024-10-10 09:08:45 +02:00
Vlad Stananddni ⚡ c6ec329328 fix: time column 2024-10-10 09:08:44 +02:00
Vlad Stananddni ⚡ cd549315f1 fix: empty currency 2024-10-10 09:08:43 +02:00
dni ⚡ 28897ca120 fixup! 2024-10-10 09:08:42 +02:00
dni ⚡ e8d10abee3 add wallet balance 2024-10-10 09:08:40 +02:00
dni ⚡ 7715e9c343 use db url 2024-10-10 09:08:39 +02:00
dni ⚡ de1a780f6d escape user 2024-10-10 09:08:38 +02:00
Vlad Stananddni ⚡ e2eb07285f chore: remove verbose key names 2024-10-10 09:08:36 +02:00
Vlad Stananddni ⚡ 343e7847df chore: format 2024-10-10 09:08:35 +02:00
Vlad Stananddni ⚡ 5cb361fe43 refactor: make settings a fixture 2024-10-10 09:08:34 +02:00
Vlad Stananddni ⚡ 4906bdcc4e fix: settings 2024-10-10 09:08:33 +02:00
Vlad Stananddni ⚡ 8725e1a2b7 fix: api tests 2024-10-10 09:08:31 +02:00
Vlad Stananddni ⚡ b0e2861aef fix: add back create_user_account 2024-10-10 09:08:30 +02:00
Vlad Stananddni ⚡ 388f9946e3 fix: some tests 2024-10-10 09:08:29 +02:00
dni ⚡ ed81ca55d9 parse date from dict 2024-10-10 09:08:27 +02:00
dni ⚡ e217e58bb6 add migration for view 2024-10-10 09:08:26 +02:00
dni ⚡ 7843a00edf fixes 2024-10-10 09:08:25 +02:00
dni ⚡ 7f3abcbd07 fix 2024-10-10 09:08:24 +02:00
Vlad Stananddni ⚡ f54c78a58d fix: account insert 2024-10-10 09:08:22 +02:00
dni ⚡ f923ecdefd fixup! 2024-10-10 09:08:20 +02:00
dni ⚡ cf03d94d4d fixup! 2024-10-10 09:08:19 +02:00
dni ⚡ 5f0068bc6d fixup! 2024-10-10 09:08:17 +02:00
dni ⚡ f85fb96063 fixup migration 2024-10-10 09:08:15 +02:00
dni ⚡ 84d9551706 fixup! 2024-10-10 09:08:13 +02:00
dni ⚡ 9ae475d4a0 fixup! 2024-10-10 09:08:12 +02:00
dni ⚡ 69e67d7d64 fixup! 2024-10-10 09:08:11 +02:00
dni ⚡ 382154b04a fix some tests 2024-10-10 09:08:10 +02:00
dni ⚡ f06b29dd82 fixup! 2024-10-10 09:08:08 +02:00
dni ⚡ 6e1b746056 fixup! 2024-10-10 09:08:07 +02:00
dni ⚡ 0b9e328363 fixup! 2024-10-10 09:08:06 +02:00
dni ⚡ f341537de2 fixup! 2024-10-10 09:08:04 +02:00
dni ⚡ 3551ee14a4 backwards compatible 2024-10-10 09:08:02 +02:00
dni ⚡ 0779535a9c fix account overview 2024-10-10 09:08:01 +02:00
dni ⚡ b0701fdb65 fix error 2024-10-10 09:07:59 +02:00
dni ⚡ 955a345900 fixup! 2024-10-10 09:07:58 +02:00
dni ⚡ aa236d99ce fixup-qrcode 2024-10-10 09:07:57 +02:00
dni ⚡ 5acddb75f5 some wallet ui fixes 2024-10-10 09:07:56 +02:00
dni ⚡ 2a17d5a8ce fixup! 2024-10-10 09:07:54 +02:00
dni ⚡ 4d75545994 revert 2024-10-10 09:07:53 +02:00
dni ⚡ cc8776ac74 fixup! 2024-10-10 09:07:52 +02:00
dni ⚡ 933c494836 more fixes 2024-10-10 09:07:50 +02:00
dni ⚡ ba843b3df4 fixup! 2024-10-10 09:07:49 +02:00
dni ⚡ 03b1a4f729 extension manager 2024-10-10 09:07:48 +02:00
dni ⚡ 0c456941e5 last fixes 2024-10-10 09:07:47 +02:00
dni ⚡ 200f5c52a7 big fx part2 2024-10-10 09:07:45 +02:00
dni ⚡ 383f6ec721 fix part1 2024-10-10 09:07:44 +02:00
Vlad Stananddni ⚡ 2410c271a6 chore: code clean-up 2024-10-10 09:07:43 +02:00
dni ⚡ 85b41c4aab remove where from argument 2024-10-10 09:07:42 +02:00
dni ⚡ cfdd81d37e fixup! 2024-10-10 09:07:40 +02:00
dni ⚡ c0db3468af more docs 2024-10-10 09:07:39 +02:00
dni ⚡ e1fb82cea5 fixup! 2024-10-10 09:07:38 +02:00
dni ⚡ 06bdd61c8e fixup! 2024-10-10 09:07:36 +02:00
dni ⚡ d813544bf4 fixup! 2024-10-10 09:07:35 +02:00
dni ⚡ cae81b61a3 fixup! 2024-10-10 09:07:34 +02:00
dni ⚡ 219e8746c5 feat: add shortcuts for insert_query and update_query into Database
example: await db.insert("table_name", base_model)
2024-10-10 09:07:29 +02:00
37 changed files with 706 additions and 1282 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 apache2 restart
service restart apache2
```
## Running behind an Nginx reverse proxy over HTTPS
@@ -468,7 +468,7 @@ EOF
Restart nginx:
```sh
service nginx restart
service restart nginx
```
## Using https without reverse proxy
-24
View File
@@ -1,24 +0,0 @@
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_db_version,
get_dbversions,
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_db_version(ext.id)
current_version = (await get_dbversions()).get(ext.id, 0)
await migrate_extension_database(ext, current_version)
# mount routes for the new version
+6 -8
View File
@@ -17,13 +17,12 @@ from lnbits.core.crud import (
delete_unused_wallets,
delete_wallet_by_id,
delete_wallet_payment,
get_db_versions,
get_dbversions,
get_installed_extension,
get_installed_extensions,
get_payment,
get_payments,
remove_deleted_wallets,
update_payment,
update_payment_status,
)
from lnbits.core.extensions.models import (
CreateExtension,
@@ -123,7 +122,7 @@ def database_migrate():
async def db_versions():
"""Show current database versions"""
async with core_db.connect() as conn:
click.echo(await get_db_versions(conn))
click.echo(await get_dbversions(conn))
@db.command("cleanup-wallets")
@@ -173,10 +172,9 @@ 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:
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.")
await update_payment_status(
status=PaymentState.PENDING, checking_id=checking_id, conn=conn
)
@db.command("cleanup-accounts")
+128 -73
View File
@@ -1,7 +1,7 @@
import json
from datetime import datetime, timezone
from time import time
from typing import Literal, Optional
from typing import Literal, Optional, Union
from uuid import uuid4
import shortuuid
@@ -25,7 +25,6 @@ from .models import (
AccountFilters,
AccountOverview,
CreatePayment,
DbVersion,
Payment,
PaymentFilters,
PaymentHistoryPoint,
@@ -36,10 +35,6 @@ from .models import (
)
def update_payment_extra():
pass
async def create_account(
account: Optional[Account] = None,
conn: Optional[Connection] = None,
@@ -166,16 +161,15 @@ async def get_account_by_username_or_email(
)
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
async def get_user(
account_or_id: Union[Account, str], 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(
@@ -310,7 +304,7 @@ async def create_user_extension(
async def update_user_extension(
user_extension: UserExtension, conn: Optional[Connection] = None
) -> None:
where = """WHERE extension = :extension AND "user" = :user"""
where = """extension = :extension AND "user" = :user"""
await (conn or db).update("extensions", user_extension, where)
@@ -482,14 +476,6 @@ 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,
@@ -691,45 +677,118 @@ async def create_payment(
previous_payment = await get_standalone_payment(checking_id, conn=conn)
assert previous_payment is None, "Payment already exists"
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 {},
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,
},
)
await (conn or db).insert("apipayments", payment)
new_payment = await get_wallet_payment(data.wallet_id, data.payment_hash, conn=conn)
assert new_payment, "Newly created payment couldn't be retrieved"
return payment
return new_payment
async def update_payment_checking_id(
checking_id: str, new_checking_id: str, conn: Optional[Connection] = None
async def update_payment_status(
checking_id: str, status: PaymentState, conn: Optional[Connection] = None
) -> None:
await (conn or db).execute(
"UPDATE apipayments SET checking_id = :new_id WHERE checking_id = :old_id",
{"new_id": new_checking_id, "old_id": checking_id},
"UPDATE apipayments SET status = :status WHERE checking_id = :checking_id",
{"status": status.value, "checking_id": checking_id},
)
async def update_payment(
payment: Payment,
async def update_payment_details(
checking_id: str,
status: Optional[PaymentState] = None,
fee: Optional[int] = None,
preimage: Optional[str] = None,
new_checking_id: Optional[str] = None,
conn: Optional[Connection] = None,
) -> None:
await (conn or db).update(
"apipayments", payment, "WHERE checking_id = :checking_id"
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},
)
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"]
@@ -810,38 +869,40 @@ async def delete_wallet_payment(
async def check_internal(
payment_hash: str, conn: Optional[Connection] = None
) -> Optional[Payment]:
) -> Optional[str]:
"""
Returns the checking_id of the internal payment if it exists,
otherwise None
"""
return await (conn or db).fetchone(
row: dict = await (conn or db).fetchone(
f"""
SELECT * FROM apipayments
SELECT checking_id 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 is_internal_status_success(
async def check_internal_status(
payment_hash: str, conn: Optional[Connection] = None
) -> bool:
"""
Returns True if the internal payment was found and is successful,
Returns True if the internal payment was successful
"""
payment = await (conn or db).fetchone(
row: dict = await (conn or db).fetchone(
"""
SELECT * FROM apipayments
SELECT status FROM apipayments
WHERE payment_hash = :payment_hash AND amount > 0
""",
{"payment_hash": payment_hash},
Payment,
)
if not payment:
return False
return payment.status == PaymentState.SUCCESS.value
if not row:
return True
return row["status"] == PaymentState.SUCCESS.value
async def mark_webhook_sent(payment_hash: str, status: int) -> None:
@@ -921,18 +982,12 @@ async def create_admin_settings(super_user: str, new_settings: dict):
# db versions
# --------------
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 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 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_db_version,
get_dbversions,
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_db_version(ext_id)
db_version = (await get_dbversions()).get(ext_id, 0)
await migrate_extension_database(ext_info, db_version)
await create_installed_extension(ext_info)
+8 -20
View File
@@ -1,6 +1,6 @@
import importlib
import re
from typing import Any, Optional
from typing import Any
from urllib.parse import urlparse
from uuid import UUID
@@ -8,20 +8,17 @@ from loguru import logger
from lnbits.core import migrations as core_migrations
from lnbits.core.crud import (
get_db_versions,
get_dbversions,
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: Optional[DbVersion] = None
):
async def migrate_extension_database(ext: InstallableExtension, current_version: int):
try:
ext_migrations = importlib.import_module(f"{ext.module_name}.migrations")
@@ -35,17 +32,14 @@ async def migrate_extension_database(
async def run_migration(
db: Connection,
migrations_module: Any,
db_name: str,
current_version: Optional[DbVersion] = None,
db: Connection, migrations_module: Any, db_name: str, current_version: int
):
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 not current_version or version > current_version.version:
if version > current_version:
logger.debug(f"running migration {db_name}.{version}")
print(f"running migration {db_name}.{version}")
await migrate(db)
@@ -77,6 +71,7 @@ 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:
@@ -92,11 +87,7 @@ async def migrate_databases():
if not exists:
await core_migrations.m000_create_migrations_table(conn)
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),
)
core_version = current_versions.get("core", 0)
await run_migration(conn, core_migrations, "core", core_version)
# here is the first place we can be sure that the
@@ -104,10 +95,7 @@ async def migrate_databases():
await load_disabled_extension_list()
for ext in await get_installed_extensions():
current_version = next(
(v for v in current_versions if v.db == ext.id),
DbVersion(db=ext.id, version=0),
)
current_version = current_versions.get(ext.id)
if current_version is None:
logger.warning(
f"Extension {ext.id} has no migration version. This should not happen."
+8 -43
View File
@@ -1,4 +1,3 @@
import json
from time import time
from loguru import logger
@@ -100,8 +99,9 @@ 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")
result = await db.execute("SELECT * FROM apipayments")
rows = result.mappings().all()
import json
rows = await db.fetchall("SELECT * FROM apipayments")
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:
result = await db.execute(
rows = await db.fetchall(
f"""
SELECT bolt11, checking_id
FROM apipayments
@@ -222,7 +222,6 @@ 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, (
@@ -340,7 +339,7 @@ async def m014_set_deleted_wallets(db):
Sets deleted column to wallets.
"""
try:
result = await db.execute(
rows = await db.fetchall(
"""
SELECT *
FROM wallets
@@ -349,13 +348,12 @@ async def m014_set_deleted_wallets(db):
AND inkey LIKE 'del:%'
"""
)
rows = result.mappings().all()
for row in rows:
try:
user = row["user"].split(":")[1]
adminkey = row["adminkey"].split(":")[1]
inkey = row["inkey"].split(":")[1]
user = row[2].split(":")[1]
adminkey = row[3].split(":")[1]
inkey = row[4].split(":")[1]
await db.execute(
"""
UPDATE wallets SET
@@ -564,8 +562,6 @@ 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")
@@ -588,34 +584,3 @@ 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"),
},
)
+26 -31
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import hashlib
import hmac
import json
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum
@@ -26,6 +27,15 @@ 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
@@ -257,51 +267,34 @@ 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
bolt11: str
amount_msat: int
amount: 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
status: str = PaymentState.PENDING
memo: Optional[str] = None
expiry: Optional[datetime] = None
webhook: Optional[str] = None
expiry: Optional[datetime]
extra: Optional[dict]
webhook: Optional[str]
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:
@@ -315,6 +308,12 @@ 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
@@ -439,6 +438,7 @@ 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,8 +479,3 @@ class BalanceDelta(BaseModel):
class SimpleStatus(BaseModel):
success: bool
message: str
class DbVersion(BaseModel):
db: str
version: int
+328 -313
View File
@@ -1,6 +1,7 @@
import asyncio
import json
import time
from datetime import datetime
from io import BytesIO
from pathlib import Path
from typing import Optional
@@ -8,8 +9,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
@@ -46,6 +47,7 @@ from lnbits.wallets.base import (
from .crud import (
check_internal,
check_internal_status,
create_account,
create_admin_settings,
create_payment,
@@ -58,12 +60,12 @@ from .crud import (
get_standalone_payment,
get_super_settings,
get_total_balance,
get_user_from_account,
get_user,
get_wallet,
get_wallet_payment,
is_internal_status_success,
update_admin_settings,
update_payment,
update_payment_details,
update_payment_status,
update_super_user,
update_user_extension,
)
@@ -82,18 +84,22 @@ from .models import (
async def calculate_fiat_amounts(
amount: float,
wallet: Wallet,
wallet_id: str,
currency: Optional[str] = None,
extra: Optional[dict] = None,
) -> tuple[int, dict]:
conn: Optional[Connection] = None,
) -> tuple[int, Optional[dict]]:
wallet = await get_wallet(wallet_id, conn=conn)
assert wallet, "invalid wallet_id"
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:
fiat_amounts["fiat_currency"] = currency
fiat_amounts["fiat_amount"] = round(amount, ndigits=3)
fiat_amounts["fiat_rate"] = amount_sat / amount
extra["fiat_currency"] = currency
extra["fiat_amount"] = round(amount, ndigits=3)
extra["fiat_rate"] = amount_sat / amount
else:
amount_sat = int(amount)
@@ -102,15 +108,16 @@ async def calculate_fiat_amounts(
fiat_amount = amount
else:
fiat_amount = await satoshis_amount_as_fiat(amount_sat, wallet_currency)
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
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
logger.debug(
f"Calculated fiat amounts {wallet.id=} {amount=} {currency=}: {fiat_amounts=}"
f"Calculated fiat amounts {wallet.id=} {amount=} {currency=}: {extra=}"
)
return amount_sat, fiat_amounts
return amount_sat, extra
async def create_invoice(
@@ -126,7 +133,7 @@ async def create_invoice(
webhook: Optional[str] = None,
internal: Optional[bool] = False,
conn: Optional[Connection] = None,
) -> Payment:
) -> tuple[str, str]:
if not amount > 0:
raise InvoiceError("Amountless invoices not supported.", status="failed")
@@ -140,7 +147,7 @@ async def create_invoice(
funding_source = fake_wallet if internal else get_funding_source()
amount_sat, extra = await calculate_fiat_amounts(
amount, user_wallet, currency, extra
amount, wallet_id, currency=currency, extra=extra, conn=conn
)
if settings.is_wallet_max_balance_exceeded(
@@ -173,191 +180,22 @@ async def create_invoice(
create_payment_model = CreatePayment(
wallet_id=wallet_id,
bolt11=payment_request,
payment_request=payment_request,
payment_hash=invoice.payment_hash,
amount_msat=amount_sat * 1000,
amount=amount_sat * 1000,
expiry=invoice.expiry_date,
memo=memo,
extra=extra,
webhook=webhook,
)
payment = await create_payment(
await create_payment(
checking_id=checking_id,
data=create_payment_model,
conn=conn,
)
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
return invoice.payment_hash, payment_request
async def pay_invoice(
@@ -367,66 +205,18 @@ async def pay_invoice(
max_sat: Optional[int] = None,
extra: Optional[dict] = None,
description: str = "",
tag: str = "",
conn: Optional[Connection] = None,
) -> 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:
) -> 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.
"""
try:
invoice = bolt11_decode(payment_request)
except Exception as exc:
@@ -434,65 +224,276 @@ def _validate_payment_request(
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")
return invoice
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
async def _credit_service_fee_wallet(
payment: Payment, conn: Optional[Connection] = None
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,
):
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,
)
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")
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_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_time_limit_between_transactions(
wallet_id: str, conn: Optional[Connection] = None
):
async def check_time_limit_between_transactions(conn, wallet_id):
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(
wallet_id: str, amount_msat: int, conn: Optional[Connection] = None
):
async def check_wallet_daily_withdraw_limit(conn, wallet_id, amount_msat):
limit = settings.lnbits_wallet_limit_daily_max_withdraw
if not limit:
return
@@ -681,7 +682,6 @@ 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,33 +701,38 @@ def fee_reserve_total(amount_msat: int, internal: bool = False) -> int:
async def send_payment_notification(wallet: Wallet, payment: Payment):
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
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})
)
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:
payment = await create_invoice(
wallet_id=wallet_id,
amount=amount,
memo="Admin top up",
internal=True,
conn=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.status = PaymentState.SUCCESS
await update_payment(payment, conn=conn)
# notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue
await internal_invoice_queue.put(payment.checking_id)
await internal_invoice_queue.put(checking_id)
async def check_admin_settings():
@@ -808,16 +813,22 @@ 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_id = super_user or uuid4().hex
account = Account(
id=account_id,
extra=UserExtra(provider="env"),
)
await create_account(account)
account = await new_account(super_user or uuid4().hex)
await create_wallet(user_id=account.id)
editable_settings = EditableSettings.from_dict(settings.dict())
@@ -855,7 +866,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_from_account(account)
user = await get_user(account)
assert user, "Cannot find user for account."
return user
@@ -882,8 +893,8 @@ class WebsocketConnectionManager:
websocket_manager = WebsocketConnectionManager()
async def websocket_updater(item_id: str, data: str):
return await websocket_manager.send_data(data, item_id)
async def websocket_updater(item_id, data):
return await websocket_manager.send_data(f"{data}", item_id)
async def switch_to_voidwallet() -> None:
@@ -913,8 +924,12 @@ async def update_pending_payments(wallet_id: str):
for payment in pending_payments:
status = await payment.check_status()
if status.failed:
payment.status = PaymentState.FAILED
await update_payment(payment)
await update_payment_status(
checking_id=payment.checking_id,
status=PaymentState.FAILED,
)
elif status.success:
payment.status = PaymentState.SUCCESS
await update_payment(payment)
await update_payment_status(
checking_id=payment.checking_id,
status=PaymentState.SUCCESS,
)
+4 -4
View File
@@ -621,8 +621,8 @@
<div v-else>
<q-responsive :ratio="1">
<qrcode-stream
@detect="decodeQR"
@camera-on="onInitQR"
@decode="decodeQR"
@init="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
@detect="decodeQR"
@camera-on="onInitQR"
@decode="decodeQR"
@init="onInitQR"
class="rounded-borders"
></qrcode-stream>
</div>
+1 -29
View File
@@ -3,7 +3,6 @@ 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
@@ -15,7 +14,6 @@ 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,
@@ -38,8 +36,6 @@ 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
@@ -51,34 +47,10 @@ 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_from_account,
get_user,
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_from_account(account)
return await get_user(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_from_account(account)
_user = await get_user(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_from_account(account)
return await get_user(account)
@auth_router.put("/first_install")
+5 -9
View File
@@ -1,5 +1,3 @@
import sys
import traceback
from http import HTTPStatus
from bolt11 import decode as bolt11_decode
@@ -42,7 +40,7 @@ from ..crud import (
create_user_extension,
delete_dbversion,
drop_extension_db,
get_db_version,
get_dbversions,
get_installed_extension,
get_installed_extensions,
get_user_extension,
@@ -86,8 +84,6 @@ 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)
@@ -434,7 +430,7 @@ async def get_pay_to_enable_invoice(
),
)
payment = await create_invoice(
payment_hash, payment_request = await create_invoice(
wallet_id=ext.meta.pay_to_enable.wallet,
amount=data.amount,
memo=f"Enable '{ext.name}' extension.",
@@ -445,10 +441,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.payment_hash
user_ext_info.payment_hash_to_enable = payment_hash
user_ext.extra = user_ext_info
await update_user_extension(user_ext)
return {"payment_hash": payment.payment_hash, "payment_request": payment.bolt11}
return {"payment_hash": payment_hash, "payment_request": payment_request}
@extension_router.get(
@@ -478,7 +474,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_db_version(ext_id)
db_version = (await get_dbversions()).get(ext_id, None)
if not db_version:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
+3 -6
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_db_versions,
get_dbversions,
get_installed_extensions,
get_user,
get_wallet,
@@ -104,8 +104,7 @@ 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_versions = await get_db_versions()
db_version = await get_dbversions()
extensions = [
{
"id": ext.id,
@@ -116,9 +115,7 @@ 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": next(
(True for version in db_versions if version.db == ext.id), False
),
"hasDatabaseTables": ext.id in db_version,
"isAvailable": ext.id in all_ext_ids,
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
"isActive": ext.id not in inactive_extensions,
+66 -33
View File
@@ -3,12 +3,13 @@ import json
import uuid
from http import HTTPStatus
from math import ceil
from typing import List, Optional
from typing import List, Optional, Union
from urllib.parse import urlparse
import httpx
from fastapi import (
APIRouter,
Body,
Depends,
Header,
HTTPException,
@@ -20,6 +21,7 @@ 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,
@@ -119,7 +121,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
@@ -143,42 +145,60 @@ async def _api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
# do not save memo if description_hash or unhashed_description is set
memo = ""
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,
)
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
# lnurl_response is not saved in the database
invoice = bolt11.decode(payment_request)
lnurl_response: Union[None, bool, str] = None
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.bolt11},
params={
"pr": payment_request,
},
timeout=10,
)
if r.is_error:
payment.extra["lnurl_response"] = r.text
lnurl_response = r.text
else:
resp = json.loads(r.text)
if resp["status"] != "OK":
payment.extra["lnurl_response"] = resp["reason"]
lnurl_response = resp["reason"]
else:
payment.extra["lnurl_response"] = True
lnurl_response = True
except (httpx.ConnectError, httpx.RequestError) as ex:
logger.error(ex)
payment.extra["lnurl_response"] = False
lnurl_response = False
return payment
return {
"payment_hash": invoice.payment_hash,
"payment_request": payment_request,
"lnurl_response": lnurl_response,
# maintain backwards compatibility with API clients:
"checking_id": checking_id,
}
@payment_router.post(
@@ -200,25 +220,30 @@ async def _api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
},
)
async def api_payments_create(
invoice_data: CreateInvoice,
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> Payment:
invoice_data: CreateInvoice = Body(...),
):
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="Missing BOLT11 invoice",
detail="BOLT11 string is invalid or not given",
)
payment = await pay_invoice(
payment_hash = await pay_invoice(
wallet_id=wallet.wallet.id,
payment_request=invoice_data.bolt11,
extra=invoice_data.extra,
)
return payment
return {
"payment_hash": payment_hash,
# maintain backwards compatibility with API clients:
"checking_id": payment_hash,
}
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,
@@ -244,7 +269,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}
@@ -288,12 +313,15 @@ 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:
@@ -302,14 +330,19 @@ 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 = await pay_invoice(
payment_hash = await pay_invoice(
wallet_id=wallet.wallet.id,
payment_request=params["pr"],
description=data.description,
extra=extra,
)
return payment
return {
"success_action": params.get("successAction"),
"payment_hash": payment_hash,
# maintain backwards compatibility with API clients:
"checking_id": payment_hash,
}
async def subscribe_wallet_invoices(request: Request, wallet: Wallet):
+2 -5
View File
@@ -175,9 +175,7 @@ class Connection(Compat):
return dict_to_model(row, model)
return row
async def update(
self, table_name: str, model: BaseModel, where: str = "WHERE id = :id"
):
async def update(self, table_name: str, model: BaseModel, where: str = "id = :id"):
await self.conn.execute(
text(update_query(table_name, model, where)), model_to_dict(model)
)
@@ -613,7 +611,7 @@ def model_to_dict(model: BaseModel) -> dict:
if isinstance(value, datetime.datetime):
_dict[key] = value.timestamp()
continue
if type(type_) is type(BaseModel) or type_ is dict:
if type(type_) is type(BaseModel):
_dict[key] = json.dumps(value)
continue
_dict[key] = value
@@ -654,7 +652,6 @@ 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_from_account(account)
user = await get_user(account)
if not user:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not found.")
await _check_user_extension_access(user.id, r["path"])
-4
View File
@@ -70,8 +70,6 @@ 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,
@@ -80,8 +78,6 @@ 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,6 +526,10 @@ 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 ?? {},
extra: data.extra ? JSON.parse(data.extra) : {},
wallet_id: data.wallet_id,
webhook: data.webhook,
webhook_status: data.webhook_status,
+4 -3
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.$emit('update:model-value', this.chips.join(','))
this.modelValue = this.chips.join(',')
},
removeChip(index) {
this.chips.splice(index, 1)
@@ -464,6 +464,7 @@ window.app.component('lnbits-dynamic-chips', {
this.chips = [...this.modelValue]
}
}
})
window.app.component('lnbits-update-balance', {
+9 -12
View File
@@ -144,11 +144,9 @@ window.app = Vue.createApp({
)
.then(response => {
this.receive.status = 'success'
this.receive.paymentReq = response.data.bolt11
this.receive.paymentReq = response.data.payment_request
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`
@@ -259,7 +257,7 @@ window.app = Vue.createApp({
})
},
decodeQR: function (res) {
this.parse.data.request = res[0].rawValue
this.parse.data.request = res
this.decodeRequest()
this.parse.camera.show = false
},
@@ -395,13 +393,12 @@ window.app = Vue.createApp({
dismissPaymentMsg()
clearInterval(this.parse.paymentChecker)
// show lnurlpay success action
const extra = response.data.extra
if (extra.success_action) {
switch (extra.success_action.tag) {
if (response.data.success_action) {
switch (response.data.success_action.tag) {
case 'url':
Quasar.Notify.create({
message: `<a target="_blank" style="color: inherit" href="${extra.success_action.url}">${extra.success_action.url}</a>`,
caption: extra.success_action.description,
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,
html: true,
type: 'positive',
timeout: 0,
@@ -410,7 +407,7 @@ window.app = Vue.createApp({
break
case 'message':
Quasar.Notify.create({
message: extra.success_action.message,
message: response.data.success_action.message,
type: 'positive',
timeout: 0,
closeBtn: true
@@ -421,14 +418,14 @@ window.app = Vue.createApp({
.getPayment(this.g.wallet, response.data.payment_hash)
.then(({data: payment}) =>
decryptLnurlPayAES(
extra.success_action,
response.data.success_action,
payment.preimage
)
)
.then(value => {
Quasar.Notify.create({
message: value,
caption: extra.success_action.description,
caption: response.data.success_action.description,
html: true,
type: 'positive',
timeout: 0,
File diff suppressed because one or more lines are too long
+20 -12
View File
@@ -20,7 +20,8 @@ from lnbits.core.crud import (
delete_webpush_subscriptions,
get_payments,
get_standalone_payment,
update_payment,
update_payment_details,
update_payment_status,
)
from lnbits.core.models import Payment, PaymentState
from lnbits.settings import settings
@@ -84,7 +85,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 await catch_everything_and_restart(func, name)
return catch_everything_and_restart(func, name)
invoice_listeners: Dict[str, asyncio.Queue] = {}
@@ -180,14 +181,17 @@ async def check_pending_payments():
status = await payment.check_status()
prefix = f"payment ({i+1} / {count})"
if status.failed:
payment.status = PaymentState.FAILED
await update_payment(payment)
await update_payment_status(
payment.checking_id, status=PaymentState.FAILED
)
logger.debug(f"{prefix} failed {payment.checking_id}")
elif status.success:
payment.fee = status.fee_msat or 0
payment.preimage = status.preimage
payment.status = PaymentState.SUCCESS
await update_payment(payment)
await update_payment_details(
checking_id=payment.checking_id,
fee=status.fee_msat,
preimage=status.preimage,
status=PaymentState.SUCCESS,
)
logger.debug(f"{prefix} success {payment.checking_id}")
else:
logger.debug(f"{prefix} pending {payment.checking_id}")
@@ -207,10 +211,14 @@ 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()
payment.fee = status.fee_msat or 0
payment.preimage = status.preimage
payment.status = PaymentState.SUCCESS
await update_payment(payment)
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"
internal = "internal" if is_internal else ""
logger.success(f"{internal} invoice {checking_id} settled")
for name, send_chan in invoice_listeners.items():
+3 -10
View File
@@ -393,7 +393,7 @@
></q-input>
<div v-else-if="o.type === 'chips'">
<lnbits-dynamic-chips
v-model="formData[o.name]"
:model-value="formData[o.name]"
@update:model-value="handleValueChanged"
></lnbits-dynamic-chips>
</div>
@@ -489,15 +489,8 @@
<template id="lnbits-qrcode">
<div class="qrcode__wrapper">
<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" />
<qrcode-vue :value="value" size="350" class="rounded-borders"></qrcode-vue>
<img class="qrcode__image" :src="logo" alt="..." />
</div>
</template>
+11 -13
View File
@@ -30,19 +30,17 @@ from .base import (
class FakeWallet(Wallet):
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()
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()
async def cleanup(self):
pass
+3 -4
View File
@@ -1320,10 +1320,9 @@
}
},
"node_modules/vue-qrcode-reader": {
"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",
"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==",
"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.115.2"
version = "0.113.0"
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
optional = false
python-versions = ">=3.8"
files = [
{file = "fastapi-0.115.2-py3-none-any.whl", hash = "sha256:61704c71286579cc5a598763905928f24ee98bfcc07aabe84cfefb98812bbc86"},
{file = "fastapi-0.115.2.tar.gz", hash = "sha256:3995739e0b09fa12f984bce8fa9ae197b35d433750d3d312422d846e283697ee"},
{file = "fastapi-0.113.0-py3-none-any.whl", hash = "sha256:c8d364485b6361fb643d53920a18d58a696e189abcb901ec03b487e35774c476"},
{file = "fastapi-0.113.0.tar.gz", hash = "sha256:b7cf9684dc154dfc93f8b718e5850577b529889096518df44defa41e73caf50f"},
]
[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.41.0"
starlette = ">=0.37.2,<0.39.0"
typing-extensions = ">=4.8.0"
[package.extras]
@@ -2730,13 +2730,13 @@ uvicorn = "*"
[[package]]
name = "starlette"
version = "0.40.0"
version = "0.37.2"
description = "The little ASGI library that shines."
optional = false
python-versions = ">=3.8"
files = [
{file = "starlette-0.40.0-py3-none-any.whl", hash = "sha256:c494a22fae73805376ea6bf88439783ecfba9aac88a43911b48c653437e784c4"},
{file = "starlette-0.40.0.tar.gz", hash = "sha256:1a3139688fb298ce5e2d661d37046a66ad996ce94be4d4983be019a23a04ea35"},
{file = "starlette-0.37.2-py3-none-any.whl", hash = "sha256:6fe59f29268538e5d0d182f2791a479a0c64638e6935d1c6989e63fb2699c6ee"},
{file = "starlette-0.37.2.tar.gz", hash = "sha256:9af890290133b79fc3db55474ade20f6220a364a0402e0b556e7cd5e1e093823"},
]
[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 = "f4847cf020ad21818fdad153d1845ed9c24d41d631aed1f6eec16158f6f295fb"
content-hash = "f9f70d510dbe30a94f68fc12fcf1d11c6fb754367e9f36e6167be2ca19e28495"
+2 -2
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "lnbits"
version = "1.0.0-rc5"
version = "1.0.0-rc3"
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.115.2"
fastapi = "0.113.0"
httpx = "0.27.0"
jinja2 = "3.1.4"
lnurl = "0.5.3"
+14 -24
View File
@@ -3,8 +3,6 @@ import asyncio
import uvloop
from lnbits.wallets.fake import FakeWallet
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
from time import time
@@ -22,12 +20,12 @@ from lnbits.core.crud import (
delete_account,
get_account,
get_account_by_username,
get_payment,
get_user_from_account,
update_payment,
get_user,
update_payment_status,
)
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
@@ -45,14 +43,18 @@ def settings():
lnbits_settings.lnbits_extensions_default_install = []
lnbits_settings.lnbits_extensions_deactivate_all = True
yield lnbits_settings
return 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)
@@ -159,7 +161,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_from_account(account)
user = await get_user(account)
yield user
@@ -219,21 +221,12 @@ async def adminkey_headers_to(to_wallet):
@pytest_asyncio.fixture(scope="session")
async def invoice(client, adminkey_headers_from):
async def invoice(to_wallet):
data = await get_random_invoice_data()
invoice_data = CreateInvoice(**data)
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()
invoice = await api_payments_create_invoice(invoice_data, to_wallet)
yield invoice
del invoice
@pytest_asyncio.fixture(scope="session")
@@ -257,9 +250,7 @@ async def fake_payments(client, adminkey_headers_from):
assert response.is_success
data = response.json()
assert data["checking_id"]
payment = await get_payment(data["checking_id"])
payment.status = PaymentState.SUCCESS
await update_payment(payment)
await update_payment_status(data["checking_id"], status=PaymentState.SUCCESS)
params = {"time[ge]": ts, "time[le]": time()}
return fake_data, params
@@ -274,4 +265,3 @@ 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 = []
+2 -3
View File
@@ -4,7 +4,7 @@ import hashlib
import pytest
from lnbits import bolt11
from lnbits.core.crud import get_standalone_payment, update_payment
from lnbits.core.crud import get_standalone_payment, update_payment_details
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,8 +303,7 @@ async def test_receive_real_invoice_set_pending_and_check_state(
assert payment
# set the incoming invoice to pending
payment.status = PaymentState.PENDING
await update_payment(payment)
await update_payment_details(payment.checking_id, status=PaymentState.PENDING)
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 = await create_invoice(
payment_hash, pr = await create_invoice(
wallet_id=from_wallet.id,
amount=1000,
memo=description,
)
invoice = decode(payment.bolt11)
assert invoice.payment_hash == payment.payment_hash
invoice = decode(pr)
assert invoice.payment_hash == payment_hash
assert invoice.amount_msat == 1000000
assert invoice.description == description
funding_source = get_funding_source()
status = await funding_source.get_invoice_status(payment.payment_hash)
status = await funding_source.get_invoice_status(payment_hash)
assert isinstance(status, PaymentStatus)
assert status.pending
@pytest.mark.asyncio
async def test_create_internal_invoice(from_wallet):
payment = await create_invoice(
payment_hash, pr = await create_invoice(
wallet_id=from_wallet.id, amount=1000, memo=description, internal=True
)
invoice = decode(payment.bolt11)
assert invoice.payment_hash == payment.payment_hash
invoice = decode(pr)
assert invoice.payment_hash == 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.payment_hash)
status = await funding_source.get_invoice_status(payment_hash)
assert isinstance(status, PaymentStatus)
assert status.pending
+15 -1
View File
@@ -1,5 +1,8 @@
import pytest
from lnbits.core.crud import (
get_standalone_payment,
)
from lnbits.core.services import (
PaymentError,
PaymentState,
@@ -11,16 +14,27 @@ description = "test pay invoice"
@pytest.mark.asyncio
async def test_services_pay_invoice(to_wallet, real_invoice):
payment = await pay_invoice(
payment_hash = 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
@@ -1,562 +0,0 @@
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