Wallet limits: max balance, daily max withdraw, transactions per sec (#2223)

Co-authored-by: benarc <ben@arc.wales>
Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
This commit is contained in:
callebtc
2024-02-09 16:25:53 +01:00
committed by GitHub
parent 20d4b954c0
commit d9d2d59b73
6 changed files with 122 additions and 3 deletions

View File

@ -200,6 +200,13 @@ LNBITS_RESERVE_FEE_MIN=2000
# value in percent # value in percent
LNBITS_RESERVE_FEE_PERCENT=1.0 LNBITS_RESERVE_FEE_PERCENT=1.0
# limit the maximum balance for each wallet
# throw an error if the wallet attempts to create a new invoice
# LNBITS_WALLET_LIMIT_MAX_BALANCE=1000000
# LNBITS_WALLET_LIMIT_DAILY_MAX_WITHDRAW=1000000
# LNBITS_WALLET_LIMIT_SECS_BETWEEN_TRANS=60
# Limit fiat currencies allowed to see in UI # Limit fiat currencies allowed to see in UI
# LNBITS_ALLOWED_CURRENCIES="EUR, USD" # LNBITS_ALLOWED_CURRENCIES="EUR, USD"

View File

@ -1,6 +1,7 @@
import asyncio import asyncio
import datetime import datetime
import json import json
import time
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Tuple, TypedDict from typing import Dict, List, Optional, Tuple, TypedDict
@ -41,6 +42,7 @@ from .crud import (
create_wallet, create_wallet,
delete_wallet_payment, delete_wallet_payment,
get_account, get_account,
get_payments,
get_standalone_payment, get_standalone_payment,
get_super_settings, get_super_settings,
get_total_balance, get_total_balance,
@ -118,8 +120,9 @@ async def create_invoice(
if not amount > 0: if not amount > 0:
raise InvoiceFailure("Amountless invoices not supported.") raise InvoiceFailure("Amountless invoices not supported.")
if await get_wallet(wallet_id, conn=conn) is None: user_wallet = await get_wallet(wallet_id, conn=conn)
raise InvoiceFailure("Wallet does not exist.") if not user_wallet:
raise InvoiceFailure(f"Could not fetch wallet '{wallet_id}'.")
invoice_memo = None if description_hash else memo invoice_memo = None if description_hash else memo
@ -130,6 +133,14 @@ async def create_invoice(
amount, wallet_id, currency=currency, extra=extra, conn=conn amount, wallet_id, currency=currency, extra=extra, conn=conn
) )
if settings.is_wallet_max_balance_exceeded(
user_wallet.balance_msat / 1000 + amount_sat
):
raise InvoiceFailure(
f"Wallet balance cannot exceed "
f"{settings.lnbits_wallet_limit_max_balance} sats."
)
ok, checking_id, payment_request, error_message = await wallet.create_invoice( ok, checking_id, payment_request, error_message = await wallet.create_invoice(
amount=amount_sat, amount=amount_sat,
memo=invoice_memo, memo=invoice_memo,
@ -185,6 +196,8 @@ async def pay_invoice(
if max_sat and invoice.amount_msat > max_sat * 1000: if max_sat and invoice.amount_msat > max_sat * 1000:
raise ValueError("Amount in invoice is too high.") raise ValueError("Amount in invoice is too high.")
await check_wallet_limits(wallet_id, conn, invoice.amount_msat)
async with db.reuse_conn(conn) if conn else db.connect() as conn: async with db.reuse_conn(conn) if conn else db.connect() as conn:
temp_id = invoice.payment_hash temp_id = invoice.payment_hash
internal_id = f"internal_{invoice.payment_hash}" internal_id = f"internal_{invoice.payment_hash}"
@ -372,6 +385,58 @@ async def pay_invoice(
return invoice.payment_hash return invoice.payment_hash
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_time_limit_between_transactions(conn, wallet_id):
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 ValueError(
f"The time limit of {limit} seconds between payments has been reached."
)
async def check_wallet_daily_withdraw_limit(conn, wallet_id, amount_msat):
limit = settings.lnbits_wallet_limit_daily_max_withdraw
if not limit or limit <= 0:
return
payments = await get_payments(
since=int(time.time()) - 60 * 60 * 24,
outgoing=True,
wallet_id=wallet_id,
limit=1,
conn=conn,
)
if len(payments) == 0:
return
total = 0
for pay in payments:
total += pay.amount
total = total - amount_msat
if limit * 1000 + total < 0:
raise ValueError(
"Daily withdrawal limit of "
+ str(settings.lnbits_wallet_limit_daily_max_withdraw)
+ " sats reached."
)
async def redeem_lnurl_withdraw( async def redeem_lnurl_withdraw(
wallet_id: str, wallet_id: str,
lnurl_request: str, lnurl_request: str,

View File

@ -180,6 +180,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-12 col-md-12"> <div class="col-12 col-md-12">
<p v-text="$t('rate_limiter')"></p> <p v-text="$t('rate_limiter')"></p>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
@ -201,6 +202,36 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-12 col-md-12">
<p v-text="$t('wallet_limiter')"></p>
<div class="row q-col-gutter-md">
<div class="col-4">
<q-input
filled
type="number"
v-model.number="formData.lnbits_wallet_limit_max_balance"
:label="$t('wallet_max_ballance')"
></q-input>
</div>
<div class="col-4">
<q-input
filled
type="number"
v-model.number="formData.lnbits_wallet_limit_daily_max_withdraw"
:label="$t('wallet_limit_max_withdraw_per_day')"
></q-input>
</div>
<div class="col-4">
<q-input
filled
type="number"
v-model.number="formData.lnbits_wallet_limit_secs_between_trans"
:label="$t('wallet_limit_secs_between_trans')"
></q-input>
</div>
</div>
</div>
</div> </div>
</div> </div>
<br /> <br />

View File

@ -116,6 +116,9 @@ class SecuritySettings(LNbitsSettings):
lnbits_notifications: bool = Field(default=False) lnbits_notifications: bool = Field(default=False)
lnbits_killswitch: bool = Field(default=False) lnbits_killswitch: bool = Field(default=False)
lnbits_killswitch_interval: int = Field(default=60) lnbits_killswitch_interval: int = Field(default=60)
lnbits_wallet_limit_max_balance: int = Field(default=0)
lnbits_wallet_limit_daily_max_withdraw: int = Field(default=0)
lnbits_wallet_limit_secs_between_trans: int = Field(default=0)
lnbits_watchdog: bool = Field(default=False) lnbits_watchdog: bool = Field(default=False)
lnbits_watchdog_interval: int = Field(default=60) lnbits_watchdog_interval: int = Field(default=60)
lnbits_watchdog_delta: int = Field(default=1_000_000) lnbits_watchdog_delta: int = Field(default=1_000_000)
@ -125,6 +128,13 @@ class SecuritySettings(LNbitsSettings):
) )
) )
def is_wallet_max_balance_exceeded(self, amount):
return (
self.lnbits_wallet_limit_max_balance
and self.lnbits_wallet_limit_max_balance > 0
and amount > self.lnbits_wallet_limit_max_balance
)
class FakeWalletFundingSource(LNbitsSettings): class FakeWalletFundingSource(LNbitsSettings):
fake_wallet_secret: str = Field(default="ToTheMoon1") fake_wallet_secret: str = Field(default="ToTheMoon1")

File diff suppressed because one or more lines are too long

View File

@ -185,6 +185,12 @@ window.localisation.en = {
allow_access_hint: 'Allow access by IP (will override blocked IPs)', allow_access_hint: 'Allow access by IP (will override blocked IPs)',
enter_ip: 'Enter IP and hit enter', enter_ip: 'Enter IP and hit enter',
rate_limiter: 'Rate Limiter', rate_limiter: 'Rate Limiter',
wallet_limiter: 'Wallet Limiter',
wallet_limit_max_withdraw_per_day:
'Max daily wallet withdrawal in sats (0 to disable)',
wallet_max_ballance: 'Wallet max balance in sats (0 to disable)',
wallet_limit_secs_between_trans:
'Min secs between transactions per wallet (0 to disable)',
number_of_requests: 'Number of requests', number_of_requests: 'Number of requests',
time_unit: 'Time unit', time_unit: 'Time unit',
minute: 'minute', minute: 'minute',