feat: add negative topups (#2835)

* feat: add negative topups
* remove topup dialog

---------

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
This commit is contained in:
dni ⚡ 2024-12-17 21:06:58 +01:00 committed by GitHub
parent 368da935db
commit 37187bfc2c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 121 additions and 145 deletions

View file

@ -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` 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 Do not forget

View file

@ -25,12 +25,12 @@ from .users import (
Account, Account,
AccountFilters, AccountFilters,
AccountOverview, AccountOverview,
CreateTopup,
CreateUser, CreateUser,
LoginUsernamePassword, LoginUsernamePassword,
LoginUsr, LoginUsr,
RegisterUser, RegisterUser,
ResetUserPassword, ResetUserPassword,
UpdateBalance,
UpdateSuperuserPassword, UpdateSuperuserPassword,
UpdateUser, UpdateUser,
UpdateUserPassword, UpdateUserPassword,
@ -73,12 +73,12 @@ __all__ = [
"Account", "Account",
"AccountFilters", "AccountFilters",
"AccountOverview", "AccountOverview",
"CreateTopup",
"CreateUser", "CreateUser",
"RegisterUser", "RegisterUser",
"LoginUsernamePassword", "LoginUsernamePassword",
"LoginUsr", "LoginUsr",
"ResetUserPassword", "ResetUserPassword",
"UpdateBalance",
"UpdateSuperuserPassword", "UpdateSuperuserPassword",
"UpdateUser", "UpdateUser",
"UpdateUserPassword", "UpdateUserPassword",

View file

@ -195,6 +195,6 @@ class AccessTokenPayload(BaseModel):
auth_time: Optional[int] = 0 auth_time: Optional[int] = 0
class CreateTopup(BaseModel): class UpdateBalance(BaseModel):
id: str id: str
amount: int amount: int

View file

@ -2,8 +2,9 @@ import json
import time import time
from typing import Optional from typing import Optional
from bolt11 import Bolt11, MilliSatoshi, Tags
from bolt11 import decode as bolt11_decode from bolt11 import decode as bolt11_decode
from bolt11.types import Bolt11 from bolt11 import encode as bolt11_encode
from loguru import logger from loguru import logger
from lnbits.core.db import db 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.decorators import check_user_extension_access
from lnbits.exceptions import InvoiceError, PaymentError from lnbits.exceptions import InvoiceError, PaymentError
from lnbits.settings import settings 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.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
from lnbits.wallets import fake_wallet, get_funding_source from lnbits.wallets import fake_wallet, get_funding_source
from lnbits.wallets.base import ( from lnbits.wallets.base import (
@ -195,12 +197,59 @@ def service_fee(amount_msat: int, internal: bool = False) -> int:
return 0 return 0
async def update_wallet_balance(wallet_id: str, amount: int): async def update_wallet_balance(
async with db.connect() as conn: 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( payment = await create_invoice(
wallet_id=wallet_id, wallet_id=wallet.id,
amount=amount, amount=amount,
memo="Admin top up", memo="Admin credit",
internal=True, internal=True,
conn=conn, conn=conn,
) )

View file

@ -37,7 +37,11 @@
<h3 class="q-my-none text-no-wrap"> <h3 class="q-my-none text-no-wrap">
<strong v-text="formattedBalance"></strong> <strong v-text="formattedBalance"></strong>
<small> {{LNBITS_DENOMINATION}}</small> <small> {{LNBITS_DENOMINATION}}</small>
<lnbits-update-balance :wallet_id="this.g.wallet.id" flat round /> <lnbits-update-balance
:wallet_id="this.g.wallet.id"
@credit-value="handleBalanceUpdate"
class="q-ml-md"
></lnbits-update-balance>
</h3> </h3>
<div class="row"> <div class="row">
<div class="col"> <div class="col">

View file

@ -48,13 +48,11 @@
<template v-slot:body="props"> <template v-slot:body="props">
<q-tr :props="props"> <q-tr :props="props">
<q-td auto-width> <q-td auto-width>
<q-btn <lnbits-update-balance
:label="$t('topup')" :wallet_id="props.row.id"
@click="showTopupDialog(props.row.id)" @credit-value="handleBalanceUpdate"
color="secondary"
size="sm"
class="q-mr-md" class="q-mr-md"
></q-btn> ></lnbits-update-balance>
<q-btn <q-btn
round round
icon="menu" icon="menu"

View file

@ -1,49 +0,0 @@
<q-dialog v-model="topupDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form class="q-gutter-md">
<p v-text="$t('topup_wallet')"></p>
<div class="row">
<div class="col-12">
<q-input
dense
type="text"
filled
v-model="wallet.id"
label="Wallet ID"
:hint="$t('topup_hint')"
></q-input>
<br />
</div>
<div class="col-12">
<q-input
dense
type="number"
filled
v-model="wallet.amount"
:label="$t('amount')"
></q-input>
</div>
</div>
<div class="row q-mt-lg">
<q-btn
:label="$t('topup')"
color="primary"
@click="topupWallet"
v-close-popup
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('cancel')"
></q-btn>
</div>
</q-form>
</q-card>
</q-dialog>

View file

@ -1,5 +1,5 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context {% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}{% include "users/_topupDialog.html" %} %} {% block page %}
<div class="row q-col-gutter-md justify-center"> <div class="row q-col-gutter-md justify-center">
<div class="col"> <div class="col">

View file

@ -24,9 +24,9 @@ from lnbits.core.crud import (
from lnbits.core.models import ( from lnbits.core.models import (
AccountFilters, AccountFilters,
AccountOverview, AccountOverview,
CreateTopup,
CreateUser, CreateUser,
SimpleStatus, SimpleStatus,
UpdateBalance,
User, User,
UserExtra, UserExtra,
Wallet, Wallet,
@ -267,16 +267,14 @@ async def api_users_delete_user_wallet(user_id: str, wallet: str) -> SimpleStatu
@users_router.put( @users_router.put(
"/topup", "/balance",
name="Topup", name="UpdateBalance",
summary="Update balance for a particular wallet.", summary="Update balance for a particular wallet.",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_super_user)], dependencies=[Depends(check_super_user)],
) )
async def api_topup_balance(data: CreateTopup) -> SimpleStatus: async def api_update_balance(data: UpdateBalance) -> SimpleStatus:
await get_wallet(data.id) wallet = await get_wallet(data.id)
if settings.lnbits_backend_wallet_class == "VoidWallet": if not wallet:
raise Exception("VoidWallet active") raise HTTPException(HTTPStatus.NOT_FOUND, "Wallet not found.")
await update_wallet_balance(wallet=wallet, amount=int(data.amount))
await update_wallet_balance(wallet_id=data.id, amount=int(data.amount))
return SimpleStatus(success=True, message="Balance updated.") return SimpleStatus(success=True, message="Balance updated.")

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -28,17 +28,17 @@ window.localisation.en = {
restart: 'Restart server', restart: 'Restart server',
save: 'Save', save: 'Save',
save_tooltip: 'Save your changes', save_tooltip: 'Save your changes',
topup: 'Topup', credit_debit: 'Credit / Debit',
topup_wallet: 'Topup a wallet', credit_hint: 'Press Enter to credit/debit wallet (negative values allowed)',
topup_hint: 'Use the wallet ID to topup any wallet', credit_label: '{denomination} to credit/debit',
credit_ok:
'Success crediting/debiting virtual funds ({amount} sats). Payments depend on actual funds on funding source.',
restart_tooltip: 'Restart the server for changes to take effect', restart_tooltip: 'Restart the server for changes to take effect',
add_funds_tooltip: 'Add funds to a wallet.', add_funds_tooltip: 'Add funds to a wallet.',
reset_defaults: 'Reset to defaults', reset_defaults: 'Reset to defaults',
reset_defaults_tooltip: 'Delete all settings and reset to defaults.', reset_defaults_tooltip: 'Delete all settings and reset to defaults.',
download_backup: 'Download database backup', download_backup: 'Download database backup',
name_your_wallet: 'Name your {name} wallet', name_your_wallet: 'Name your {name} wallet',
wallet_topup_ok:
'Success creating virtual funds ({amount} sats). Payments depend on actual funds on funding source.',
paste_invoice_label: 'Paste an invoice, payment request or lnurl code *', paste_invoice_label: 'Paste an invoice, payment request or lnurl code *',
lnbits_description: lnbits_description:
'Easy to set up and lightweight, LNbits can run on any Lightning Network funding source and even LNbits itself! You can run LNbits for yourself, or easily offer a custodian solution for others. Each wallet has its own API keys and there is no limit to the number of wallets you can make. Being able to partition funds makes LNbits a useful tool for money management and as a development tool. Extensions add extra functionality to LNbits so you can experiment with a range of cutting-edge technologies on the lightning network. We have made developing extensions as easy as possible, and as a free and open-source project, we encourage people to develop and submit their own.', 'Easy to set up and lightweight, LNbits can run on any Lightning Network funding source and even LNbits itself! You can run LNbits for yourself, or easily offer a custodian solution for others. Each wallet has its own API keys and there is no limit to the number of wallets you can make. Being able to partition funds makes LNbits a useful tool for money management and as a development tool. Extensions add extra functionality to LNbits so you can experiment with a range of cutting-edge technologies on the lightning network. We have made developing extensions as easy as possible, and as a free and open-source project, we encourage people to develop and submit their own.',
@ -71,8 +71,6 @@ window.localisation.en = {
api_keys_api_docs: 'Node URL, API keys and API docs', api_keys_api_docs: 'Node URL, API keys and API docs',
lnbits_version: 'LNbits version', lnbits_version: 'LNbits version',
runs_on: 'Runs on', runs_on: 'Runs on',
credit_hint: 'Press Enter to credit account',
credit_label: '{denomination} to credit',
paste: 'Paste', paste: 'Paste',
paste_from_clipboard: 'Paste from clipboard', paste_from_clipboard: 'Paste from clipboard',
paste_request: 'Paste Request', paste_request: 'Paste Request',

View file

@ -167,7 +167,7 @@ window.LNbits = {
) )
}, },
updateBalance(credit, wallet_id) { updateBalance(credit, wallet_id) {
return this.request('PUT', '/users/api/v1/topup', null, { return this.request('PUT', '/users/api/v1/balance', null, {
amount: credit, amount: credit,
id: wallet_id id: wallet_id
}) })

View file

@ -484,7 +484,7 @@ window.app.component('lnbits-dynamic-chips', {
window.app.component('lnbits-update-balance', { window.app.component('lnbits-update-balance', {
template: '#lnbits-update-balance', template: '#lnbits-update-balance',
mixins: [window.windowMixin], mixins: [window.windowMixin],
props: ['wallet_id'], props: ['wallet_id', 'credit-value'],
computed: { computed: {
denomination() { denomination() {
return LNBITS_DENOMINATION return LNBITS_DENOMINATION
@ -514,11 +514,12 @@ window.app.component('lnbits-update-balance', {
credit = parseInt(credit) credit = parseInt(credit)
Quasar.Notify.create({ Quasar.Notify.create({
type: 'positive', type: 'positive',
message: this.$t('wallet_topup_ok', { message: this.$t('credit_ok', {
amount: credit amount: credit
}), }),
icon: null icon: null
}) })
this.$emit('credit-value', credit)
return credit return credit
}) })
.catch(LNbits.utils.notifyApiError) .catch(LNbits.utils.notifyApiError)

View file

@ -4,7 +4,6 @@ window.app = Vue.createApp({
data() { data() {
return { return {
paymentsWallet: {}, paymentsWallet: {},
wallet: {},
cancel: {}, cancel: {},
users: [], users: [],
wallets: [], wallets: [],
@ -21,9 +20,6 @@ window.app = Vue.createApp({
userId: null, userId: null,
show: false show: false
}, },
topupDialog: {
show: false
},
activeUser: { activeUser: {
data: null, data: null,
showUserId: false, showUserId: false,
@ -184,6 +180,9 @@ window.app = Vue.createApp({
this.activeWallet.show = false this.activeWallet.show = false
this.fetchUsers() this.fetchUsers()
}, },
handleBalanceUpdate() {
this.fetchWallets(this.activeWallet.userId)
},
resetPassword(user_id) { resetPassword(user_id) {
return LNbits.api return LNbits.api
.request('PUT', `/users/api/v1/user/${user_id}/reset_password`) .request('PUT', `/users/api/v1/user/${user_id}/reset_password`)
@ -383,43 +382,10 @@ window.app = Vue.createApp({
this.activeUser.show = false this.activeUser.show = false
} }
}, },
showTopupDialog(walletId) {
this.wallet.id = walletId
this.topupDialog.show = true
},
showPayments(wallet_id) { showPayments(wallet_id) {
this.paymentsWallet = this.wallets.find(wallet => wallet.id === wallet_id) this.paymentsWallet = this.wallets.find(wallet => wallet.id === wallet_id)
this.paymentPage.show = true this.paymentPage.show = true
}, },
topupCallback(res) {
if (res.success) {
this.wallets.forEach(wallet => {
if (res.wallet_id === wallet.id) {
wallet.balance_msat += res.credit * 1000
}
})
this.fetchUsers()
}
},
topupWallet() {
LNbits.api
.request(
'PUT',
'/users/api/v1/topup',
this.g.user.wallets[0].adminkey,
this.wallet
)
.then(_ => {
Quasar.Notify.create({
type: 'positive',
message: `Added ${this.wallet.amount} to ${this.wallet.id}`,
icon: null
})
this.wallet = {}
this.fetchWallets(this.activeWallet.userId)
})
.catch(LNbits.utils.notifyApiError)
},
searchUserBy(fieldName) { searchUserBy(fieldName) {
const fieldValue = this.searchData[fieldName] const fieldValue = this.searchData[fieldName]
this.usersTable.filter = {} this.usersTable.filter = {}

View file

@ -51,7 +51,6 @@ window.app = Vue.createApp({
balance: parseInt(wallet.balance_msat / 1000), balance: parseInt(wallet.balance_msat / 1000),
fiatBalance: 0, fiatBalance: 0,
mobileSimple: false, mobileSimple: false,
credit: 0,
update: { update: {
name: null, name: null,
currency: null currency: null
@ -151,6 +150,9 @@ window.app = Vue.createApp({
this.receive.paymentHash = null this.receive.paymentHash = null
} }
}, },
handleBalanceUpdate(value) {
this.balance = this.balance + value
},
createInvoice() { createInvoice() {
this.receive.status = 'loading' this.receive.status = 'loading'
if (LNBITS_DENOMINATION != 'sats') { if (LNBITS_DENOMINATION != 'sats') {

View file

@ -506,12 +506,11 @@
</template> </template>
<template id="lnbits-update-balance"> <template id="lnbits-update-balance">
<q-btn v-if="admin" round color="primary" icon="add" size="sm"> <q-btn v-if="admin" :label="$t('credit_debit')" color="secondary" size="sm">
<q-popup-edit class="bg-accent text-white" v-slot="scope" v-model="credit"> <q-popup-edit class="bg-accent text-white" v-slot="scope" v-model="credit">
<q-input <q-input
filled filled
:label="$t('credit_label', {denomination: denomination})" :label="$t('credit_label', {denomination: denomination})"
:hint="$t('credit_hint')"
v-model="scope.value" v-model="scope.value"
dense dense
autofocus autofocus
@ -522,7 +521,7 @@
</template> </template>
</q-input> </q-input>
</q-popup-edit> </q-popup-edit>
<q-tooltip>Topup Wallet</q-tooltip> <q-tooltip v-text="$t('credit_hint')"></q-tooltip>
</q-btn> </q-btn>
</template> </template>

View file

@ -1,6 +1,6 @@
import base64 import base64
import getpass import getpass
from hashlib import md5 from hashlib import md5, pbkdf2_hmac, sha256
from Cryptodome import Random from Cryptodome import Random
from Cryptodome.Cipher import AES from Cryptodome.Cipher import AES
@ -8,6 +8,21 @@ from Cryptodome.Cipher import AES
BLOCK_SIZE = 16 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: class AESCipher:
"""This class is compatible with crypto-js/aes.js """This class is compatible with crypto-js/aes.js

View file

@ -1,6 +1,6 @@
import asyncio import asyncio
import hashlib
from datetime import datetime from datetime import datetime
from hashlib import sha256
from os import urandom from os import urandom
from typing import AsyncGenerator, Dict, Optional, Set from typing import AsyncGenerator, Dict, Optional, Set
@ -16,6 +16,7 @@ from bolt11 import (
from loguru import logger from loguru import logger
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.crypto import fake_privkey
from .base import ( from .base import (
InvoiceResponse, InvoiceResponse,
@ -35,14 +36,8 @@ class FakeWallet(Wallet):
self.queue: asyncio.Queue = asyncio.Queue(0) self.queue: asyncio.Queue = asyncio.Queue(0)
self.payment_secrets: Dict[str, str] = {} self.payment_secrets: Dict[str, str] = {}
self.paid_invoices: Set[str] = set() self.paid_invoices: Set[str] = set()
self.secret: str = settings.fake_wallet_secret self.secret = settings.fake_wallet_secret
self.privkey: str = hashlib.pbkdf2_hmac( self.privkey = fake_privkey(self.secret)
"sha256",
self.secret.encode(),
b"FakeWallet",
2048,
32,
).hex()
async def cleanup(self): async def cleanup(self):
pass pass
@ -71,7 +66,7 @@ class FakeWallet(Wallet):
elif unhashed_description: elif unhashed_description:
tags.add( tags.add(
TagChar.description_hash, TagChar.description_hash,
hashlib.sha256(unhashed_description).hexdigest(), sha256(unhashed_description).hexdigest(),
) )
else: else:
tags.add(TagChar.description, memo or "") tags.add(TagChar.description, memo or "")
@ -85,7 +80,7 @@ class FakeWallet(Wallet):
secret = urandom(32).hex() secret = urandom(32).hex()
tags.add(TagChar.payment_secret, secret) 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) tags.add(TagChar.payment_hash, payment_hash)

View file

@ -121,7 +121,7 @@ async def from_wallet(from_user):
wallet = await create_wallet(user_id=user.id, wallet_name="test_wallet_from") wallet = await create_wallet(user_id=user.id, wallet_name="test_wallet_from")
await update_wallet_balance( await update_wallet_balance(
wallet_id=wallet.id, wallet=wallet,
amount=999999999, amount=999999999,
) )
yield wallet yield wallet
@ -138,7 +138,7 @@ async def to_wallet_pagination_tests(to_user):
@pytest.fixture @pytest.fixture
async def from_wallet_ws(from_wallet, test_client): 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) await asyncio.sleep(0.1)
with test_client.websocket_connect(f"/api/v1/ws/{from_wallet.inkey}") as ws: with test_client.websocket_connect(f"/api/v1/ws/{from_wallet.inkey}") as ws:
yield ws yield ws
@ -171,7 +171,7 @@ async def to_wallet(to_user):
user = to_user user = to_user
wallet = await create_wallet(user_id=user.id, wallet_name="test_wallet_to") wallet = await create_wallet(user_id=user.id, wallet_name="test_wallet_to")
await update_wallet_balance( await update_wallet_balance(
wallet_id=wallet.id, wallet=wallet,
amount=999999999, amount=999999999,
) )
yield wallet yield wallet
@ -186,7 +186,7 @@ async def to_fresh_wallet(to_user):
@pytest.fixture @pytest.fixture
async def to_wallet_ws(to_wallet, test_client): 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) await asyncio.sleep(0.1)
with test_client.websocket_connect(f"/api/v1/ws/{to_wallet.inkey}") as ws: with test_client.websocket_connect(f"/api/v1/ws/{to_wallet.inkey}") as ws:
yield ws yield ws