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