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
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`
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

View File

@@ -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",

View File

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

View File

@@ -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,
)

View File

@@ -37,7 +37,11 @@
<h3 class="q-my-none text-no-wrap">
<strong v-text="formattedBalance"></strong>
<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>
<div class="row">
<div class="col">

View File

@@ -48,13 +48,11 @@
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
:label="$t('topup')"
@click="showTopupDialog(props.row.id)"
color="secondary"
size="sm"
<lnbits-update-balance
:wallet_id="props.row.id"
@credit-value="handleBalanceUpdate"
class="q-mr-md"
></q-btn>
></lnbits-update-balance>
<q-btn
round
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
%} {% block page %}{% include "users/_topupDialog.html" %}
%} {% block page %}
<div class="row q-col-gutter-md justify-center">
<div class="col">

View File

@@ -24,9 +24,9 @@ from lnbits.core.crud import (
from lnbits.core.models import (
AccountFilters,
AccountOverview,
CreateTopup,
CreateUser,
SimpleStatus,
UpdateBalance,
User,
UserExtra,
Wallet,
@@ -267,16 +267,14 @@ async def api_users_delete_user_wallet(user_id: str, wallet: str) -> SimpleStatu
@users_router.put(
"/topup",
name="Topup",
"/balance",
name="UpdateBalance",
summary="Update balance for a particular wallet.",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_super_user)],
)
async def api_topup_balance(data: CreateTopup) -> SimpleStatus:
await get_wallet(data.id)
if settings.lnbits_backend_wallet_class == "VoidWallet":
raise Exception("VoidWallet active")
await update_wallet_balance(wallet_id=data.id, amount=int(data.amount))
async def api_update_balance(data: UpdateBalance) -> SimpleStatus:
wallet = await get_wallet(data.id)
if not wallet:
raise HTTPException(HTTPStatus.NOT_FOUND, "Wallet not found.")
await update_wallet_balance(wallet=wallet, amount=int(data.amount))
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',
save: 'Save',
save_tooltip: 'Save your changes',
topup: 'Topup',
topup_wallet: 'Topup a wallet',
topup_hint: 'Use the wallet ID to topup any wallet',
credit_debit: 'Credit / Debit',
credit_hint: 'Press Enter to credit/debit wallet (negative values allowed)',
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',
add_funds_tooltip: 'Add funds to a wallet.',
reset_defaults: 'Reset to defaults',
reset_defaults_tooltip: 'Delete all settings and reset to defaults.',
download_backup: 'Download database backup',
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 *',
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.',
@@ -71,8 +71,6 @@ window.localisation.en = {
api_keys_api_docs: 'Node URL, API keys and API docs',
lnbits_version: 'LNbits version',
runs_on: 'Runs on',
credit_hint: 'Press Enter to credit account',
credit_label: '{denomination} to credit',
paste: 'Paste',
paste_from_clipboard: 'Paste from clipboard',
paste_request: 'Paste Request',

View File

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

View File

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

View File

@@ -4,7 +4,6 @@ window.app = Vue.createApp({
data() {
return {
paymentsWallet: {},
wallet: {},
cancel: {},
users: [],
wallets: [],
@@ -21,9 +20,6 @@ window.app = Vue.createApp({
userId: null,
show: false
},
topupDialog: {
show: false
},
activeUser: {
data: null,
showUserId: false,
@@ -184,6 +180,9 @@ window.app = Vue.createApp({
this.activeWallet.show = false
this.fetchUsers()
},
handleBalanceUpdate() {
this.fetchWallets(this.activeWallet.userId)
},
resetPassword(user_id) {
return LNbits.api
.request('PUT', `/users/api/v1/user/${user_id}/reset_password`)
@@ -383,43 +382,10 @@ window.app = Vue.createApp({
this.activeUser.show = false
}
},
showTopupDialog(walletId) {
this.wallet.id = walletId
this.topupDialog.show = true
},
showPayments(wallet_id) {
this.paymentsWallet = this.wallets.find(wallet => wallet.id === wallet_id)
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) {
const fieldValue = this.searchData[fieldName]
this.usersTable.filter = {}

View File

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

View File

@@ -506,12 +506,11 @@
</template>
<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-input
filled
:label="$t('credit_label', {denomination: denomination})"
:hint="$t('credit_hint')"
v-model="scope.value"
dense
autofocus
@@ -522,7 +521,7 @@
</template>
</q-input>
</q-popup-edit>
<q-tooltip>Topup Wallet</q-tooltip>
<q-tooltip v-text="$t('credit_hint')"></q-tooltip>
</q-btn>
</template>

View File

@@ -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

View File

@@ -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)

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")
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