mirror of
https://github.com/lnbits/lnbits.git
synced 2025-10-04 18:33:10 +02:00
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:
@@ -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
|
||||
|
||||
|
@@ -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",
|
||||
|
@@ -195,6 +195,6 @@ class AccessTokenPayload(BaseModel):
|
||||
auth_time: Optional[int] = 0
|
||||
|
||||
|
||||
class CreateTopup(BaseModel):
|
||||
class UpdateBalance(BaseModel):
|
||||
id: str
|
||||
amount: int
|
||||
|
@@ -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,
|
||||
)
|
||||
|
@@ -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">
|
||||
|
@@ -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"
|
||||
|
@@ -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>
|
@@ -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">
|
||||
|
@@ -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.")
|
||||
|
2
lnbits/static/bundle-components.min.js
vendored
2
lnbits/static/bundle-components.min.js
vendored
File diff suppressed because one or more lines are too long
2
lnbits/static/bundle.min.js
vendored
2
lnbits/static/bundle.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -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',
|
||||
|
@@ -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
|
||||
})
|
||||
|
@@ -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)
|
||||
|
@@ -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 = {}
|
||||
|
@@ -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') {
|
||||
|
@@ -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>
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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)
|
||||
|
||||
|
@@ -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
|
||||
|
Reference in New Issue
Block a user