mirror of
https://github.com/lnbits/lnbits-legend.git
synced 2025-01-18 21:32:38 +01:00
Add service fee to specific wallet (#2050)
* add service fee to specific wallet
* add to .env.example
* Added service fee wallet to manage server
* cleaned
* prettier
* Added badge for service fee
* Added tooltip
* Added service fee max
* allow ignoring service fee for internal transactions
* add fee_reserve_total helper funciton that includes service_fee
* html for admin ui
* typo
* Update .env.example
Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
* fix .env.template comment
* bundle
* WIP: expose fee reserve endpoint
---------
Co-authored-by: Arc <ben@arc.wales>
Co-authored-by: dni ⚡ <office@dnilabs.com>
Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
This commit is contained in:
parent
4dcf26bcb3
commit
6a27b91fcb
@ -78,7 +78,13 @@ LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos"
|
||||
LNBITS_DATA_FOLDER="./data"
|
||||
# LNBITS_DATABASE_URL="postgres://user:password@host:port/databasename"
|
||||
|
||||
LNBITS_SERVICE_FEE="0.0"
|
||||
# Value in percent
|
||||
LNBITS_SERVICE_FEE=0.0
|
||||
# Max fee to charge per transaction (in satoshi), 0 will leave no max and use LNBITS_SERVICE_FEE %
|
||||
LNBITS_SERVICE_FEE_MAX=0
|
||||
# The wallet where the service fee will be sent to
|
||||
LNBITS_SERVICE_FEE_WALLET=""
|
||||
|
||||
# value in millisats
|
||||
LNBITS_RESERVE_FEE_MIN=2000
|
||||
# value in percent
|
||||
|
@ -426,6 +426,8 @@ def log_server_info():
|
||||
logger.info(f"Data folder: {settings.lnbits_data_folder}")
|
||||
logger.info(f"Database: {get_db_vendor_name()}")
|
||||
logger.info(f"Service fee: {settings.lnbits_service_fee}")
|
||||
logger.info(f"Service fee max: {settings.lnbits_service_fee_max}")
|
||||
logger.info(f"Service fee wallet: {settings.lnbits_service_fee_wallet}")
|
||||
|
||||
|
||||
def get_db_vendor_name():
|
||||
|
@ -185,7 +185,6 @@ async def pay_invoice(
|
||||
if max_sat and invoice.amount_msat > max_sat * 1000:
|
||||
raise ValueError("Amount in invoice is too high.")
|
||||
|
||||
fee_reserve_msat = fee_reserve(invoice.amount_msat)
|
||||
async with db.reuse_conn(conn) if conn else db.connect() as conn:
|
||||
temp_id = invoice.payment_hash
|
||||
internal_id = f"internal_{invoice.payment_hash}"
|
||||
@ -228,6 +227,9 @@ async def pay_invoice(
|
||||
# (pending only)
|
||||
internal_checking_id = await check_internal(invoice.payment_hash, conn=conn)
|
||||
if internal_checking_id:
|
||||
fee_reserve_total_msat = fee_reserve_total(
|
||||
invoice.amount_msat, internal=True
|
||||
)
|
||||
# perform additional checks on the internal payment
|
||||
# the payment hash is not enough to make sure that this is the same invoice
|
||||
internal_invoice = await get_standalone_payment(
|
||||
@ -244,19 +246,22 @@ async def pay_invoice(
|
||||
# create a new payment from this wallet
|
||||
new_payment = await create_payment(
|
||||
checking_id=internal_id,
|
||||
fee=0,
|
||||
fee=0 + abs(fee_reserve_total_msat),
|
||||
pending=False,
|
||||
conn=conn,
|
||||
**payment_kwargs,
|
||||
)
|
||||
else:
|
||||
fee_reserve_total_msat = fee_reserve_total(
|
||||
invoice.amount_msat, internal=False
|
||||
)
|
||||
logger.debug(f"creating temporary payment with id {temp_id}")
|
||||
# create a temporary payment here so we can check if
|
||||
# the balance is enough in the next step
|
||||
try:
|
||||
new_payment = await create_payment(
|
||||
checking_id=temp_id,
|
||||
fee=-fee_reserve_msat,
|
||||
fee=-abs(fee_reserve_total_msat),
|
||||
conn=conn,
|
||||
**payment_kwargs,
|
||||
)
|
||||
@ -270,14 +275,18 @@ async def pay_invoice(
|
||||
assert wallet, "Wallet for balancecheck could not be fetched"
|
||||
if wallet.balance_msat < 0:
|
||||
logger.debug("balance is too low, deleting temporary payment")
|
||||
if not internal_checking_id and wallet.balance_msat > -fee_reserve_msat:
|
||||
if (
|
||||
not internal_checking_id
|
||||
and wallet.balance_msat > -fee_reserve_total_msat
|
||||
):
|
||||
raise PaymentFailure(
|
||||
f"You must reserve at least ({round(fee_reserve_msat/1000)} sat) to"
|
||||
" cover potential routing fees."
|
||||
f"You must reserve at least ({round(fee_reserve_total_msat/1000)}"
|
||||
" sat) to cover potential routing fees."
|
||||
)
|
||||
raise PermissionError("Insufficient balance.")
|
||||
|
||||
if internal_checking_id:
|
||||
service_fee_msat = service_fee(invoice.amount_msat, internal=True)
|
||||
logger.debug(f"marking temporary payment as not pending {internal_checking_id}")
|
||||
# mark the invoice from the other side as not pending anymore
|
||||
# so the other side only has access to his new money when we are sure
|
||||
@ -294,6 +303,8 @@ async def pay_invoice(
|
||||
logger.debug(f"enqueuing internal invoice {internal_checking_id}")
|
||||
await internal_invoice_queue.put(internal_checking_id)
|
||||
else:
|
||||
fee_reserve_msat = fee_reserve(invoice.amount_msat, internal=False)
|
||||
service_fee_msat = service_fee(invoice.amount_msat, internal=False)
|
||||
logger.debug(f"backend: sending payment {temp_id}")
|
||||
# actually pay the external invoice
|
||||
WALLET = get_wallet_class()
|
||||
@ -315,7 +326,10 @@ async def pay_invoice(
|
||||
await update_payment_details(
|
||||
checking_id=temp_id,
|
||||
pending=payment.ok is not True,
|
||||
fee=payment.fee_msat,
|
||||
fee=-(
|
||||
abs(payment.fee_msat if payment.fee_msat else 0)
|
||||
+ abs(service_fee_msat)
|
||||
),
|
||||
preimage=payment.preimage,
|
||||
new_checking_id=payment.checking_id,
|
||||
conn=conn,
|
||||
@ -343,6 +357,18 @@ async def pay_invoice(
|
||||
f" database: {temp_id}"
|
||||
)
|
||||
|
||||
# credit service fee wallet
|
||||
if settings.lnbits_service_fee_wallet and service_fee_msat:
|
||||
new_payment = await create_payment(
|
||||
wallet_id=settings.lnbits_service_fee_wallet,
|
||||
fee=0,
|
||||
amount=abs(service_fee_msat),
|
||||
memo="service fee",
|
||||
checking_id="service_fee" + temp_id,
|
||||
payment_request=payment_request,
|
||||
payment_hash=invoice.payment_hash,
|
||||
pending=False,
|
||||
)
|
||||
return invoice.payment_hash
|
||||
|
||||
|
||||
@ -496,12 +522,31 @@ async def check_transaction_status(
|
||||
|
||||
# WARN: this same value must be used for balance check and passed to
|
||||
# WALLET.pay_invoice(), it may cause a vulnerability if the values differ
|
||||
def fee_reserve(amount_msat: int) -> int:
|
||||
def fee_reserve(amount_msat: int, internal: bool = False) -> int:
|
||||
reserve_min = settings.lnbits_reserve_fee_min
|
||||
reserve_percent = settings.lnbits_reserve_fee_percent
|
||||
return max(int(reserve_min), int(amount_msat * reserve_percent / 100.0))
|
||||
|
||||
|
||||
def service_fee(amount_msat: int, internal: bool = False) -> int:
|
||||
service_fee_percent = settings.lnbits_service_fee
|
||||
fee_max = settings.lnbits_service_fee_max * 1000
|
||||
if settings.lnbits_service_fee_wallet:
|
||||
if internal and settings.lnbits_service_fee_ignore_internal:
|
||||
return 0
|
||||
fee_percentage = int(amount_msat / 100 * service_fee_percent)
|
||||
if fee_max > 0 and fee_percentage > fee_max:
|
||||
return fee_max
|
||||
else:
|
||||
return fee_percentage
|
||||
else:
|
||||
return 0
|
||||
|
||||
|
||||
def fee_reserve_total(amount_msat: int, internal: bool = False) -> int:
|
||||
return fee_reserve(amount_msat, internal) + service_fee(amount_msat, internal)
|
||||
|
||||
|
||||
async def send_payment_notification(wallet: Wallet, payment: Payment):
|
||||
await websocketUpdater(
|
||||
wallet.id,
|
||||
|
@ -85,6 +85,64 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h6 class="q-mt-xl q-mb-md">Service Fees</h6>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Service Fee</p>
|
||||
<q-input
|
||||
filled
|
||||
type="number"
|
||||
v-model.number="formData.lnbits_service_fee"
|
||||
label="Service fee (%)"
|
||||
step="0.1"
|
||||
hint="Fee charged per tx (%)"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Service fee max</p>
|
||||
<q-input
|
||||
filled
|
||||
type="number"
|
||||
v-model.number="formData.lnbits_service_fee_max"
|
||||
label="Service fee max (sats)"
|
||||
hint="Max service fee to charge in (sats)"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Fee Wallet</p>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_service_fee_wallet"
|
||||
label="Fee wallet (wallet ID)"
|
||||
hint="Wallet ID to send funds to"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Disable Service Fee for Internal Payments</p>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Disable Fee</q-item-label>
|
||||
<q-item-label caption
|
||||
>Disable Service Fee for Internal Lightning
|
||||
Payments</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_service_fee_ignore_internal"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isSuperUser">
|
||||
<lnbits-funding-sources
|
||||
:form-data="formData"
|
||||
|
@ -19,41 +19,6 @@
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Service Fee</p>
|
||||
<q-input
|
||||
filled
|
||||
type="number"
|
||||
v-model.number="formData.lnbits_service_fee"
|
||||
label="Service fee (%)"
|
||||
step="0.1"
|
||||
hint="Fee charged per tx (%)"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Miscellaneous</p>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Hide API</q-item-label>
|
||||
<q-item-label caption
|
||||
>Hides wallet api, extensions can choose to honor</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_hide_api"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Allowed currencies</p>
|
||||
@ -93,6 +58,27 @@
|
||||
></q-select>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Miscellaneous</p>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Hide API</q-item-label>
|
||||
<q-item-label caption
|
||||
>Hides wallet api, extensions can choose to honor</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_hide_api"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
@ -104,6 +104,7 @@ from ..services import (
|
||||
PaymentFailure,
|
||||
check_transaction_status,
|
||||
create_invoice,
|
||||
fee_reserve_total,
|
||||
pay_invoice,
|
||||
perform_lnurlauth,
|
||||
websocketManager,
|
||||
@ -386,6 +387,21 @@ async def api_payments_create(
|
||||
)
|
||||
|
||||
|
||||
@api_router.get("/api/v1/payments/fee-reserve")
|
||||
async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse:
|
||||
invoice_obj = bolt11.decode(invoice)
|
||||
if invoice_obj.amount_msat:
|
||||
response = {
|
||||
"fee_reserve": fee_reserve_total(invoice_obj.amount_msat),
|
||||
}
|
||||
return JSONResponse(response)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Invoice has no amount.",
|
||||
)
|
||||
|
||||
|
||||
@api_router.post("/api/v1/payments/lnurl")
|
||||
async def api_payments_pay_lnurl(
|
||||
data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
|
@ -214,6 +214,7 @@ async def wallet(
|
||||
"user": user.dict(),
|
||||
"wallet": userwallet.dict(),
|
||||
"service_fee": settings.lnbits_service_fee,
|
||||
"service_fee_max": settings.lnbits_service_fee_max,
|
||||
"web_manifest": f"/manifest/{user.id}.webmanifest",
|
||||
},
|
||||
)
|
||||
|
@ -59,6 +59,9 @@ def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templa
|
||||
t.env.globals["LNBITS_VERSION"] = settings.version
|
||||
t.env.globals["LNBITS_NEW_ACCOUNTS_ALLOWED"] = settings.new_accounts_allowed
|
||||
t.env.globals["LNBITS_ADMIN_UI"] = settings.lnbits_admin_ui
|
||||
t.env.globals["LNBITS_SERVICE_FEE"] = settings.lnbits_service_fee
|
||||
t.env.globals["LNBITS_SERVICE_FEE_MAX"] = settings.lnbits_service_fee_max
|
||||
t.env.globals["LNBITS_SERVICE_FEE_WALLET"] = settings.lnbits_service_fee_wallet
|
||||
t.env.globals["LNBITS_NODE_UI"] = (
|
||||
settings.lnbits_node_ui and get_node_class() is not None
|
||||
)
|
||||
|
@ -99,6 +99,9 @@ class OpsSettings(LNbitsSettings):
|
||||
lnbits_reserve_fee_min: int = Field(default=2000)
|
||||
lnbits_reserve_fee_percent: float = Field(default=1.0)
|
||||
lnbits_service_fee: float = Field(default=0)
|
||||
lnbits_service_fee_ignore_internal: bool = Field(default=True)
|
||||
lnbits_service_fee_max: int = Field(default=0)
|
||||
lnbits_service_fee_wallet: str = Field(default=None)
|
||||
lnbits_hide_api: bool = Field(default=False)
|
||||
lnbits_denomination: str = Field(default="sats")
|
||||
|
||||
|
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
@ -54,6 +54,10 @@ window.localisation.en = {
|
||||
view_github: 'View on GitHub',
|
||||
voidwallet_active: 'VoidWallet is active! Payments disabled',
|
||||
use_with_caution: 'USE WITH CAUTION - %{name} wallet is still in BETA',
|
||||
service_fee: 'Service fee: %{amount} % per transaction',
|
||||
service_fee_max: 'Service fee: %{amount} % per transaction (max %{max} sats)',
|
||||
service_fee_tooltip:
|
||||
'Service fee charged by the LNbits server admin per outgoing transaction',
|
||||
toggle_darkmode: 'Toggle Dark Mode',
|
||||
view_swagger_docs: 'View LNbits Swagger API docs',
|
||||
api_docs: 'API docs',
|
||||
|
@ -1,6 +1,6 @@
|
||||
// update cache version every time there is a new deployment
|
||||
// so the service worker reinitializes the cache
|
||||
const CACHE_VERSION = 72
|
||||
const CACHE_VERSION = 82
|
||||
const CURRENT_CACHE = `lnbits-${CACHE_VERSION}-`
|
||||
|
||||
const getApiKey = request => {
|
||||
|
@ -74,7 +74,29 @@
|
||||
v-text='$t("use_with_caution", { name: "{{ SITE_TITLE }}" })'
|
||||
></span>
|
||||
</q-badge>
|
||||
{% endblock %}
|
||||
{% if LNBITS_SERVICE_FEE > 0 %}
|
||||
<q-badge
|
||||
v-show="$q.screen.gt.sm"
|
||||
v-if="g.user"
|
||||
color="green"
|
||||
text-color="black"
|
||||
class="q-mr-md"
|
||||
>
|
||||
{% if LNBITS_SERVICE_FEE_MAX > 0 %}
|
||||
<span
|
||||
v-text='$t("service_fee_max", { amount: "{{ LNBITS_SERVICE_FEE }}", max: "{{ LNBITS_SERVICE_FEE_MAX }}"})'
|
||||
></span>
|
||||
{%else%}
|
||||
<span
|
||||
v-text='$t("service_fee", { amount: "{{ LNBITS_SERVICE_FEE }}" })'
|
||||
></span>
|
||||
{%endif%}
|
||||
<q-tooltip
|
||||
><span v-text='$t("service_fee_tooltip")'></span
|
||||
></q-tooltip>
|
||||
</q-badge>
|
||||
|
||||
{%endif%} {% endblock %}
|
||||
<q-badge
|
||||
v-if="g.offline"
|
||||
color="red"
|
||||
|
@ -6,6 +6,7 @@ import pytest
|
||||
from lnbits import bolt11
|
||||
from lnbits.core.crud import get_standalone_payment, update_payment_details
|
||||
from lnbits.core.models import CreateInvoice, Payment
|
||||
from lnbits.core.services import fee_reserve_total
|
||||
from lnbits.core.views.admin_api import api_auditor
|
||||
from lnbits.core.views.api import api_payment
|
||||
from lnbits.settings import settings
|
||||
@ -14,6 +15,7 @@ from lnbits.wallets import get_wallet_class
|
||||
from ...helpers import (
|
||||
cancel_invoice,
|
||||
get_random_invoice_data,
|
||||
get_real_invoice,
|
||||
is_fake,
|
||||
is_regtest,
|
||||
pay_real_invoice,
|
||||
@ -845,3 +847,32 @@ async def test_receive_real_invoice_set_pending_and_check_state(
|
||||
assert payment_by_checking_id.pending is False
|
||||
assert payment_by_checking_id.bolt11 == payment_not_pending.bolt11
|
||||
assert payment_by_checking_id.payment_hash == payment_not_pending.payment_hash
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_fee_reserve(client, adminkey_headers_from):
|
||||
# if regtest, create a real invoice, otherwise create an internal invoice
|
||||
# call /api/v1/payments/fee-reserve?invoice=... with it and check if the fee reserve
|
||||
# is correct
|
||||
payment_request = ""
|
||||
if is_regtest:
|
||||
real_invoice = get_real_invoice(1000)
|
||||
payment_request = real_invoice["payment_request"]
|
||||
|
||||
else:
|
||||
create_invoice = CreateInvoice(out=False, amount=1000, memo="test")
|
||||
response = await client.post(
|
||||
"/api/v1/payments",
|
||||
json=create_invoice.dict(),
|
||||
headers=adminkey_headers_from,
|
||||
)
|
||||
assert response.status_code < 300
|
||||
invoice = response.json()
|
||||
payment_request = invoice["payment_request"]
|
||||
|
||||
response = await client.get(
|
||||
f"/api/v1/payments/fee-reserve?invoice={payment_request}",
|
||||
)
|
||||
assert response.status_code < 300
|
||||
fee_reserve = response.json()
|
||||
assert fee_reserve["fee_reserve"] == fee_reserve_total(1000_000)
|
||||
|
Loading…
Reference in New Issue
Block a user