Wallets: add custom invoice expiry (#1396)
* expiry for fakewallet * expiry for lnd * lnbits backend * fix: eclair descriptionHash fixed and expiry added * cln and sparko * test expiry * Eclair from AdminUI and bugfix for nonexistent payments * add to settings and .env and remove lntxbot * remove duplicate and format * add invoice expiry * add min max and step * UI works now * test should fail, sanity check, will revert * revert, ready for merge Co-authored-by: Tiago Vasconcelos <talvasconcelos@gmail.com>
This commit is contained in:
parent
5a0b217d63
commit
f0d58a8365
|
@ -63,6 +63,9 @@ LNBITS_BACKEND_WALLET_CLASS=VoidWallet
|
|||
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
|
||||
# just so you can see the UI before dealing with this file.
|
||||
|
||||
# Invoice expiry for LND, CLN, Eclair, LNbits funding sources
|
||||
LIGHTNING_INVOICE_EXPIRY=600
|
||||
|
||||
# Set one of these blocks depending on the wallet kind you chose above:
|
||||
|
||||
# ClicheWallet
|
||||
|
|
|
@ -64,6 +64,7 @@ async def create_invoice(
|
|||
memo: str,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
expiry: Optional[int] = None,
|
||||
extra: Optional[Dict] = None,
|
||||
webhook: Optional[str] = None,
|
||||
internal: Optional[bool] = False,
|
||||
|
@ -79,6 +80,7 @@ async def create_invoice(
|
|||
memo=invoice_memo,
|
||||
description_hash=description_hash,
|
||||
unhashed_description=unhashed_description,
|
||||
expiry=expiry or settings.lightning_invoice_expiry,
|
||||
)
|
||||
if not ok:
|
||||
raise InvoiceFailure(error_message or "unexpected backend error.")
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<p>Active Funding<small> (Requires server restart)</small></p>
|
||||
|
@ -30,8 +30,19 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="col-12">
|
||||
<div class="col-12 col-md-8">
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-4">
|
||||
<p>Invoice Expiry</p>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lightning_invoice_expiry"
|
||||
label="Invoice expiry (seconds)"
|
||||
mask="#######"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-12 col-md-8">
|
||||
<p>Fee reserve</p>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-6">
|
||||
|
@ -57,6 +68,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isSuperUser">
|
||||
<p class="q-my-md">
|
||||
Funding Sources<small> (Requires server restart)</small>
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
:disabled="!checkChanges"
|
||||
>
|
||||
<q-tooltip v-if="checkChanges"> Save your changes </q-tooltip>
|
||||
|
||||
<q-badge
|
||||
v-if="checkChanges"
|
||||
color="red"
|
||||
|
@ -17,6 +18,7 @@
|
|||
style="padding: 6px; border-radius: 6px"
|
||||
/>
|
||||
</q-btn>
|
||||
|
||||
<q-btn
|
||||
v-if="isSuperUser"
|
||||
label="Restart server"
|
||||
|
@ -26,6 +28,7 @@
|
|||
<q-tooltip v-if="needsRestart">
|
||||
Restart the server for changes to take effect
|
||||
</q-tooltip>
|
||||
|
||||
<q-badge
|
||||
v-if="needsRestart"
|
||||
color="red"
|
||||
|
@ -34,6 +37,7 @@
|
|||
style="padding: 6px; border-radius: 6px"
|
||||
/>
|
||||
</q-btn>
|
||||
|
||||
<q-btn
|
||||
v-if="isSuperUser"
|
||||
label="Topup"
|
||||
|
@ -42,11 +46,13 @@
|
|||
>
|
||||
<q-tooltip> Add funds to a wallet. </q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<!-- <q-btn
|
||||
label="Download Database Backup"
|
||||
flat
|
||||
@click="downloadBackup"
|
||||
></q-btn> -->
|
||||
|
||||
<q-btn
|
||||
flat
|
||||
v-if="isSuperUser"
|
||||
|
@ -59,6 +65,7 @@
|
|||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-col-gutter-md justify-center">
|
||||
<div class="col q-gutter-y-md">
|
||||
<q-card>
|
||||
|
@ -70,16 +77,19 @@
|
|||
label="Funding"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
|
||||
<q-tab
|
||||
name="users"
|
||||
label="Users"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
|
||||
<q-tab
|
||||
name="server"
|
||||
label="Server"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
|
||||
<q-tab
|
||||
name="theme"
|
||||
label="Theme"
|
||||
|
@ -88,6 +98,7 @@
|
|||
</q-tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-form name="settings_form" id="settings_form">
|
||||
<q-tab-panels v-model="tab" animated>
|
||||
{% include "admin/_tab_funding.html" %} {% include
|
||||
|
@ -98,10 +109,12 @@
|
|||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-dialog v-if="isSuperUser" 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>TopUp a wallet</p>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
|
@ -112,8 +125,10 @@
|
|||
label="Wallet ID"
|
||||
hint="Use the wallet ID to topup any wallet"
|
||||
></q-input>
|
||||
|
||||
<br />
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
dense
|
||||
|
@ -124,14 +139,15 @@
|
|||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn label="Topup" color="primary" @click="topupWallet"></q-btn>
|
||||
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script>
|
||||
new Vue({
|
||||
|
@ -173,7 +189,7 @@
|
|||
}
|
||||
],
|
||||
[
|
||||
'CLightningWallet',
|
||||
'CoreLightningWallet',
|
||||
{
|
||||
corelightning_rpc: {
|
||||
value: null,
|
||||
|
@ -244,15 +260,15 @@
|
|||
}
|
||||
],
|
||||
[
|
||||
'LntxbotWallet',
|
||||
'LnTipsWallet',
|
||||
{
|
||||
lntxbot_api_endpoint: {
|
||||
lntips_api_endpoint: {
|
||||
value: null,
|
||||
label: 'Endpoint'
|
||||
},
|
||||
lntxbot_key: {
|
||||
lntips_api_key: {
|
||||
value: null,
|
||||
label: 'Key'
|
||||
label: 'API Key'
|
||||
}
|
||||
}
|
||||
],
|
||||
|
@ -278,7 +294,7 @@
|
|||
{
|
||||
eclair_url: {
|
||||
value: null,
|
||||
label: 'Endpoint'
|
||||
label: 'URL'
|
||||
},
|
||||
eclair_pass: {
|
||||
value: null,
|
||||
|
@ -333,19 +349,6 @@
|
|||
label: 'Token'
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
'LnTipsWallet',
|
||||
{
|
||||
lntips_api_endpoint: {
|
||||
value: null,
|
||||
label: 'Endpoint'
|
||||
},
|
||||
lntips_api_key: {
|
||||
value: null,
|
||||
label: 'API Key'
|
||||
}
|
||||
}
|
||||
]
|
||||
])
|
||||
}
|
||||
|
|
|
@ -48,9 +48,9 @@
|
|||
<code>{"X-Api-Key": "<i>{{ wallet.inkey }}</i>"}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code
|
||||
>{"out": false, "amount": <int>, "memo": <string>, "unit":
|
||||
<string>, "webhook": <url:string>, "internal":
|
||||
<bool>}</code
|
||||
>{"out": false, "amount": <int>, "memo": <string>,
|
||||
"expiry": <int>, "unit": <string>, "webhook":
|
||||
<url:string>, "internal": <bool>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
|
@ -62,8 +62,7 @@
|
|||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.base_url }}api/v1/payments -d '{"out": false,
|
||||
"amount": <int>, "memo": <string>, "webhook":
|
||||
<url:string>, "unit": <string>}' -H "X-Api-Key:
|
||||
"amount": <int>, "memo": <string>}' -H "X-Api-Key:
|
||||
<i>{{ wallet.inkey }}</i>" -H "Content-type: application/json"</code
|
||||
>
|
||||
</q-card-section>
|
||||
|
|
|
@ -133,6 +133,7 @@ class CreateInvoiceData(BaseModel):
|
|||
unit: Optional[str] = "sat"
|
||||
description_hash: Optional[str] = None
|
||||
unhashed_description: Optional[str] = None
|
||||
expiry: Optional[int] = None
|
||||
lnurl_callback: Optional[str] = None
|
||||
lnurl_balance_check: Optional[str] = None
|
||||
extra: Optional[dict] = None
|
||||
|
@ -178,6 +179,7 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
|||
memo=memo,
|
||||
description_hash=description_hash,
|
||||
unhashed_description=unhashed_description,
|
||||
expiry=data.expiry,
|
||||
extra=data.extra,
|
||||
webhook=data.webhook,
|
||||
internal=data.internal,
|
||||
|
|
|
@ -149,6 +149,10 @@ class BoltzExtensionSettings(LNbitsSettings):
|
|||
boltz_mempool_space_url_ws: str = Field(default="wss://mempool.space")
|
||||
|
||||
|
||||
class LightningSettings(LNbitsSettings):
|
||||
lightning_invoice_expiry: int = Field(default=600)
|
||||
|
||||
|
||||
class FundingSourcesSettings(
|
||||
FakeWalletFundingSource,
|
||||
LNbitsFundingSource,
|
||||
|
@ -172,6 +176,7 @@ class EditableSettings(
|
|||
OpsSettings,
|
||||
FundingSourcesSettings,
|
||||
BoltzExtensionSettings,
|
||||
LightningSettings,
|
||||
):
|
||||
@validator(
|
||||
"lnbits_admin_users",
|
||||
|
@ -217,20 +222,23 @@ class SuperUserSettings(LNbitsSettings):
|
|||
default=[
|
||||
"VoidWallet",
|
||||
"FakeWallet",
|
||||
"CLightningWallet",
|
||||
"CoreLightningWallet",
|
||||
"LndRestWallet",
|
||||
"EclairWallet",
|
||||
"LndWallet",
|
||||
"LntxbotWallet",
|
||||
"LnTipsWallet",
|
||||
"LNPayWallet",
|
||||
"LNbitsWallet",
|
||||
"OpenNodeWallet",
|
||||
"LnTipsWallet",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class ReadOnlySettings(
|
||||
EnvSettings, SaaSSettings, PersistenceSettings, SuperUserSettings
|
||||
EnvSettings,
|
||||
SaaSSettings,
|
||||
PersistenceSettings,
|
||||
SuperUserSettings,
|
||||
):
|
||||
lnbits_admin_ui: bool = Field(default=False)
|
||||
|
||||
|
|
|
@ -48,6 +48,7 @@ class ClicheWallet(Wallet):
|
|||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
**kwargs,
|
||||
) -> InvoiceResponse:
|
||||
if unhashed_description or description_hash:
|
||||
description_hash_str = (
|
||||
|
|
|
@ -83,6 +83,7 @@ class CoreLightningWallet(Wallet):
|
|||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
**kwargs,
|
||||
) -> InvoiceResponse:
|
||||
label = "lbl{}".format(random.random())
|
||||
msat: int = int(amount * 1000)
|
||||
|
@ -103,6 +104,7 @@ class CoreLightningWallet(Wallet):
|
|||
deschashonly=True
|
||||
if unhashed_description
|
||||
else False, # we can't pass None here
|
||||
expiry=kwargs.get("expiry"),
|
||||
)
|
||||
|
||||
if r.get("code") and r.get("code") < 0:
|
||||
|
|
|
@ -73,13 +73,17 @@ class EclairWallet(Wallet):
|
|||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
**kwargs,
|
||||
) -> InvoiceResponse:
|
||||
|
||||
data: Dict = {"amountMsat": amount * 1000}
|
||||
if kwargs.get("expiry"):
|
||||
data["expireIn"] = kwargs["expiry"]
|
||||
|
||||
if description_hash:
|
||||
data["description_hash"] = description_hash.hex()
|
||||
data["descriptionHash"] = description_hash.hex()
|
||||
elif unhashed_description:
|
||||
data["description_hash"] = hashlib.sha256(unhashed_description).hexdigest()
|
||||
data["descriptionHash"] = hashlib.sha256(unhashed_description).hexdigest()
|
||||
else:
|
||||
data["description"] = memo or ""
|
||||
|
||||
|
@ -162,16 +166,19 @@ class EclairWallet(Wallet):
|
|||
)
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.post(
|
||||
f"{self.url}/getreceivedinfo",
|
||||
headers=self.auth,
|
||||
data={"paymentHash": checking_id},
|
||||
)
|
||||
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
|
||||
if r.is_error or "error" in data or data.get("status") is None:
|
||||
return PaymentStatus(None)
|
||||
raise Exception("error in eclair response")
|
||||
|
||||
statuses = {
|
||||
"received": True,
|
||||
|
@ -179,8 +186,11 @@ class EclairWallet(Wallet):
|
|||
"pending": None,
|
||||
}
|
||||
return PaymentStatus(statuses.get(data["status"]["type"]))
|
||||
except:
|
||||
return PaymentStatus(None)
|
||||
|
||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.post(
|
||||
f"{self.url}/getsentinfo",
|
||||
|
@ -189,13 +199,12 @@ class EclairWallet(Wallet):
|
|||
timeout=40,
|
||||
)
|
||||
|
||||
if r.is_error:
|
||||
return PaymentStatus(None)
|
||||
r.raise_for_status()
|
||||
|
||||
data = r.json()[-1]
|
||||
|
||||
if r.is_error or "error" in data or data.get("status") is None:
|
||||
return PaymentStatus(None)
|
||||
raise Exception("error in eclair response")
|
||||
|
||||
fee_msat, preimage = None, None
|
||||
if data["status"]["type"] == "sent":
|
||||
|
@ -207,7 +216,11 @@ class EclairWallet(Wallet):
|
|||
"failed": False,
|
||||
"pending": None,
|
||||
}
|
||||
return PaymentStatus(statuses.get(data["status"]["type"]), fee_msat, preimage)
|
||||
return PaymentStatus(
|
||||
statuses.get(data["status"]["type"]), fee_msat, preimage
|
||||
)
|
||||
except:
|
||||
return PaymentStatus(None)
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
while True:
|
||||
|
|
|
@ -41,6 +41,7 @@ class FakeWallet(Wallet):
|
|||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
**kwargs,
|
||||
) -> InvoiceResponse:
|
||||
data: Dict = {
|
||||
"out": False,
|
||||
|
@ -54,6 +55,7 @@ class FakeWallet(Wallet):
|
|||
"expires": None,
|
||||
"route": None,
|
||||
}
|
||||
data["expires"] = kwargs.get("expiry")
|
||||
data["amount"] = amount * 1000
|
||||
data["timestamp"] = datetime.now().timestamp()
|
||||
if description_hash:
|
||||
|
|
|
@ -59,8 +59,11 @@ class LNbitsWallet(Wallet):
|
|||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
**kwargs,
|
||||
) -> InvoiceResponse:
|
||||
data: Dict = {"out": False, "amount": amount}
|
||||
if kwargs.get("expiry"):
|
||||
data["expiry"] = kwargs["expiry"]
|
||||
if description_hash:
|
||||
data["description_hash"] = description_hash.hex()
|
||||
if unhashed_description:
|
||||
|
|
|
@ -154,8 +154,11 @@ class LndWallet(Wallet):
|
|||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
**kwargs,
|
||||
) -> InvoiceResponse:
|
||||
params: Dict = {"value": amount, "expiry": 600, "private": True}
|
||||
params: Dict = {"value": amount, "private": True}
|
||||
if kwargs.get("expiry"):
|
||||
params["expiry"] = kwargs["expiry"]
|
||||
if description_hash:
|
||||
params["description_hash"] = description_hash
|
||||
elif unhashed_description:
|
||||
|
|
|
@ -77,6 +77,8 @@ class LndRestWallet(Wallet):
|
|||
**kwargs,
|
||||
) -> InvoiceResponse:
|
||||
data: Dict = {"value": amount, "private": True}
|
||||
if kwargs.get("expiry"):
|
||||
data["expiry"] = kwargs["expiry"]
|
||||
if description_hash:
|
||||
data["description_hash"] = base64.b64encode(description_hash).decode(
|
||||
"ascii"
|
||||
|
|
|
@ -119,6 +119,7 @@ class SparkWallet(Wallet):
|
|||
label=label,
|
||||
description=memo or "",
|
||||
exposeprivatechannels=True,
|
||||
expiry=kwargs.get("expiry"),
|
||||
)
|
||||
ok, payment_request, error_message = True, r["bolt11"], ""
|
||||
except (SparkError, UnknownError) as e:
|
||||
|
|
|
@ -94,6 +94,21 @@ async def test_create_internal_invoice(client, inkey_headers_to):
|
|||
return invoice
|
||||
|
||||
|
||||
# check POST /api/v1/payments: invoice with custom expiry
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_invoice_custom_expiry(client, inkey_headers_to):
|
||||
data = await get_random_invoice_data()
|
||||
expiry_seconds = 600 * 6 * 24 * 31 # 31 days in the future
|
||||
data["expiry"] = expiry_seconds
|
||||
response = await client.post(
|
||||
"/api/v1/payments", json=data, headers=inkey_headers_to
|
||||
)
|
||||
assert response.status_code == 201
|
||||
invoice = response.json()
|
||||
bolt11_invoice = bolt11.decode(invoice["payment_request"])
|
||||
assert bolt11_invoice.expiry == expiry_seconds
|
||||
|
||||
|
||||
# check POST /api/v1/payments: make payment
|
||||
@pytest.mark.asyncio
|
||||
async def test_pay_invoice(client, invoice, adminkey_headers_from):
|
||||
|
|
Loading…
Reference in New Issue
Block a user