From 37187bfc2cdb55e02297dd980c1568f21e883619 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Tue, 17 Dec 2024 21:06:58 +0100 Subject: [PATCH] feat: add negative topups (#2835) * feat: add negative topups * remove topup dialog --------- Co-authored-by: Vlad Stan --- docs/guide/admin_ui.md | 2 +- lnbits/core/models/__init__.py | 4 +- lnbits/core/models/users.py | 2 +- lnbits/core/services/payments.py | 59 +++++++++++++++++-- lnbits/core/templates/core/wallet.html | 6 +- .../core/templates/users/_manageWallet.html | 10 ++-- lnbits/core/templates/users/_topupDialog.html | 49 --------------- lnbits/core/templates/users/index.html | 2 +- lnbits/core/views/user_api.py | 18 +++--- lnbits/static/bundle-components.min.js | 2 +- lnbits/static/bundle.min.js | 2 +- lnbits/static/i18n/en.js | 12 ++-- lnbits/static/js/base.js | 2 +- lnbits/static/js/components.js | 5 +- lnbits/static/js/users.js | 40 +------------ lnbits/static/js/wallet.js | 4 +- lnbits/templates/components.vue | 5 +- lnbits/utils/crypto.py | 17 +++++- lnbits/wallets/fake.py | 17 ++---- tests/conftest.py | 8 +-- 20 files changed, 121 insertions(+), 145 deletions(-) delete mode 100644 lnbits/core/templates/users/_topupDialog.html diff --git a/docs/guide/admin_ui.md b/docs/guide/admin_ui.md index 5b4da3f7c..0ec4af157 100644 --- a/docs/guide/admin_ui.md +++ b/docs/guide/admin_ui.md @@ -67,7 +67,7 @@ You can access your super user account at `/wallet?usr=super_user_id`. You just After that you will find the **`Admin` / `Manage Server`** between `Wallets` and `Extensions` -Here you can design the interface, it has TOPUP to fill wallets and you can restrict access rights to extensions only for admins or generally deactivated for everyone. You can make users admins or set up Allowed Users if you want to restrict access. And of course the classic settings of the .env file, e.g. to change the funding source wallet or set a charge fee. +Here you can design the interface, it has credit/debit to change wallets balances and you can restrict access rights to extensions only for admins or generally deactivated for everyone. You can make users admins or set up Allowed Users if you want to restrict access. And of course the classic settings of the .env file, e.g. to change the funding source wallet or set a charge fee. Do not forget diff --git a/lnbits/core/models/__init__.py b/lnbits/core/models/__init__.py index 4b33064a8..8ab336d20 100644 --- a/lnbits/core/models/__init__.py +++ b/lnbits/core/models/__init__.py @@ -25,12 +25,12 @@ from .users import ( Account, AccountFilters, AccountOverview, - CreateTopup, CreateUser, LoginUsernamePassword, LoginUsr, RegisterUser, ResetUserPassword, + UpdateBalance, UpdateSuperuserPassword, UpdateUser, UpdateUserPassword, @@ -73,12 +73,12 @@ __all__ = [ "Account", "AccountFilters", "AccountOverview", - "CreateTopup", "CreateUser", "RegisterUser", "LoginUsernamePassword", "LoginUsr", "ResetUserPassword", + "UpdateBalance", "UpdateSuperuserPassword", "UpdateUser", "UpdateUserPassword", diff --git a/lnbits/core/models/users.py b/lnbits/core/models/users.py index cda1faf48..13bf0d8ba 100644 --- a/lnbits/core/models/users.py +++ b/lnbits/core/models/users.py @@ -195,6 +195,6 @@ class AccessTokenPayload(BaseModel): auth_time: Optional[int] = 0 -class CreateTopup(BaseModel): +class UpdateBalance(BaseModel): id: str amount: int diff --git a/lnbits/core/services/payments.py b/lnbits/core/services/payments.py index 6901aabe3..785843ef8 100644 --- a/lnbits/core/services/payments.py +++ b/lnbits/core/services/payments.py @@ -2,8 +2,9 @@ import json import time from typing import Optional +from bolt11 import Bolt11, MilliSatoshi, Tags from bolt11 import decode as bolt11_decode -from bolt11.types import Bolt11 +from bolt11 import encode as bolt11_encode from loguru import logger from lnbits.core.db import db @@ -11,6 +12,7 @@ from lnbits.db import Connection from lnbits.decorators import check_user_extension_access from lnbits.exceptions import InvoiceError, PaymentError from lnbits.settings import settings +from lnbits.utils.crypto import fake_privkey, random_secret_and_hash from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat from lnbits.wallets import fake_wallet, get_funding_source from lnbits.wallets.base import ( @@ -195,12 +197,59 @@ def service_fee(amount_msat: int, internal: bool = False) -> int: return 0 -async def update_wallet_balance(wallet_id: str, amount: int): - async with db.connect() as conn: +async def update_wallet_balance( + wallet: Wallet, + amount: int, + conn: Optional[Connection] = None, +): + if amount == 0: + raise ValueError("Amount cannot be 0.") + + # negative balance change + if amount < 0: + if wallet.balance + amount < 0: + raise ValueError("Balance change failed, can not go into negative balance.") + async with db.reuse_conn(conn) if conn else db.connect() as conn: + payment_secret, payment_hash = random_secret_and_hash() + invoice = Bolt11( + currency="bc", + amount_msat=MilliSatoshi(abs(amount) * 1000), + date=int(time.time()), + tags=Tags.from_dict( + { + "payment_hash": payment_hash, + "payment_secret": payment_secret, + "description": "Admin debit", + } + ), + ) + privkey = fake_privkey(settings.fake_wallet_secret) + bolt11 = bolt11_encode(invoice, privkey) + await create_payment( + checking_id=f"internal_{payment_hash}", + data=CreatePayment( + wallet_id=wallet.id, + bolt11=bolt11, + payment_hash=payment_hash, + amount_msat=amount * 1000, + memo="Admin debit", + ), + status=PaymentState.SUCCESS, + conn=conn, + ) + return None + + # positive balance change + if ( + settings.lnbits_wallet_limit_max_balance > 0 + and wallet.balance + amount > settings.lnbits_wallet_limit_max_balance + ): + raise ValueError("Balance change failed, amount exceeds maximum balance.") + async with db.reuse_conn(conn) if conn else db.connect() as conn: payment = await create_invoice( - wallet_id=wallet_id, + wallet_id=wallet.id, amount=amount, - memo="Admin top up", + memo="Admin credit", internal=True, conn=conn, ) diff --git a/lnbits/core/templates/core/wallet.html b/lnbits/core/templates/core/wallet.html index 6a0e826c4..58ff78e43 100644 --- a/lnbits/core/templates/core/wallet.html +++ b/lnbits/core/templates/core/wallet.html @@ -37,7 +37,11 @@

{{LNBITS_DENOMINATION}} - +

diff --git a/lnbits/core/templates/users/_manageWallet.html b/lnbits/core/templates/users/_manageWallet.html index 4f8c4a7f2..fee7f0c00 100644 --- a/lnbits/core/templates/users/_manageWallet.html +++ b/lnbits/core/templates/users/_manageWallet.html @@ -48,13 +48,11 @@ diff --git a/lnbits/utils/crypto.py b/lnbits/utils/crypto.py index ebecd19e2..6d81a2acf 100644 --- a/lnbits/utils/crypto.py +++ b/lnbits/utils/crypto.py @@ -1,6 +1,6 @@ import base64 import getpass -from hashlib import md5 +from hashlib import md5, pbkdf2_hmac, sha256 from Cryptodome import Random from Cryptodome.Cipher import AES @@ -8,6 +8,21 @@ from Cryptodome.Cipher import AES BLOCK_SIZE = 16 +def random_secret_and_hash() -> tuple[str, str]: + secret = Random.new().read(32) + return secret.hex(), sha256(secret).hexdigest() + + +def fake_privkey(secret: str) -> str: + return pbkdf2_hmac( + "sha256", + secret.encode(), + b"FakeWallet", + 2048, + 32, + ).hex() + + class AESCipher: """This class is compatible with crypto-js/aes.js diff --git a/lnbits/wallets/fake.py b/lnbits/wallets/fake.py index 676f3a4b5..bacdf42c0 100644 --- a/lnbits/wallets/fake.py +++ b/lnbits/wallets/fake.py @@ -1,6 +1,6 @@ import asyncio -import hashlib from datetime import datetime +from hashlib import sha256 from os import urandom from typing import AsyncGenerator, Dict, Optional, Set @@ -16,6 +16,7 @@ from bolt11 import ( from loguru import logger from lnbits.settings import settings +from lnbits.utils.crypto import fake_privkey from .base import ( InvoiceResponse, @@ -35,14 +36,8 @@ class FakeWallet(Wallet): 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() + self.secret = settings.fake_wallet_secret + self.privkey = fake_privkey(self.secret) async def cleanup(self): pass @@ -71,7 +66,7 @@ class FakeWallet(Wallet): elif unhashed_description: tags.add( TagChar.description_hash, - hashlib.sha256(unhashed_description).hexdigest(), + sha256(unhashed_description).hexdigest(), ) else: tags.add(TagChar.description, memo or "") @@ -85,7 +80,7 @@ class FakeWallet(Wallet): secret = urandom(32).hex() tags.add(TagChar.payment_secret, secret) - payment_hash = hashlib.sha256(secret.encode()).hexdigest() + payment_hash = sha256(secret.encode()).hexdigest() tags.add(TagChar.payment_hash, payment_hash) diff --git a/tests/conftest.py b/tests/conftest.py index 76b848482..d5e92b6ef 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -121,7 +121,7 @@ async def from_wallet(from_user): wallet = await create_wallet(user_id=user.id, wallet_name="test_wallet_from") await update_wallet_balance( - wallet_id=wallet.id, + wallet=wallet, amount=999999999, ) yield wallet @@ -138,7 +138,7 @@ async def to_wallet_pagination_tests(to_user): @pytest.fixture async def from_wallet_ws(from_wallet, test_client): - # wait a bit in order to avoid receiving topup notification + # wait a bit in order to avoid receiving change_balance notification await asyncio.sleep(0.1) with test_client.websocket_connect(f"/api/v1/ws/{from_wallet.inkey}") as ws: yield ws @@ -171,7 +171,7 @@ async def to_wallet(to_user): user = to_user wallet = await create_wallet(user_id=user.id, wallet_name="test_wallet_to") await update_wallet_balance( - wallet_id=wallet.id, + wallet=wallet, amount=999999999, ) yield wallet @@ -186,7 +186,7 @@ async def to_fresh_wallet(to_user): @pytest.fixture async def to_wallet_ws(to_wallet, test_client): - # wait a bit in order to avoid receiving topup notification + # wait a bit in order to avoid receiving change_balance notification await asyncio.sleep(0.1) with test_client.websocket_connect(f"/api/v1/ws/{to_wallet.inkey}") as ws: yield ws