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:
calle 2023-01-26 11:08:40 +01:00 committed by GitHub
parent 5a0b217d63
commit f0d58a8365
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 166 additions and 95 deletions

View File

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

View File

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

View File

@ -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,28 +30,40 @@
</div>
</div>
</div>
<div class="col-12 col-md-6">
<div class="col-12">
<p>Fee reserve</p>
<div class="row q-col-gutter-md">
<div class="col-6">
<q-input
type="number"
filled
v-model="formData.lnbits_reserve_fee_min"
label="Reserve fee in msats"
>
</q-input>
</div>
<div class="col-6">
<q-input
type="number"
filled
name="lnbits_reserve_fee_percent"
v-model="formData.lnbits_reserve_fee_percent"
label="Reserve fee in percent"
step="0.1"
></q-input>
<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">
<q-input
type="number"
filled
v-model="formData.lnbits_reserve_fee_min"
label="Reserve fee in msats"
>
</q-input>
</div>
<div class="col-6">
<q-input
type="number"
filled
name="lnbits_reserve_fee_percent"
v-model="formData.lnbits_reserve_fee_percent"
label="Reserve fee in percent"
step="0.1"
></q-input>
</div>
</div>
</div>
</div>

View File

@ -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'
}
}
]
])
}

View File

@ -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": &lt;int&gt;, "memo": &lt;string&gt;, "unit":
&lt;string&gt;, "webhook": &lt;url:string&gt;, "internal":
&lt;bool&gt;}</code
>{"out": false, "amount": &lt;int&gt;, "memo": &lt;string&gt;,
"expiry": &lt;int&gt;, "unit": &lt;string&gt;, "webhook":
&lt;url:string&gt;, "internal": &lt;bool&gt;}</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": &lt;int&gt;, "memo": &lt;string&gt;, "webhook":
&lt;url:string&gt;, "unit": &lt;string&gt;}' -H "X-Api-Key:
"amount": &lt;int&gt;, "memo": &lt;string&gt;}' -H "X-Api-Key:
<i>{{ wallet.inkey }}</i>" -H "Content-type: application/json"</code
>
</q-card-section>

View File

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

View File

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

View File

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

View File

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

View File

@ -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,53 +166,62 @@ class EclairWallet(Wallet):
)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
async with httpx.AsyncClient() as client:
r = await client.post(
f"{self.url}/getreceivedinfo",
headers=self.auth,
data={"paymentHash": checking_id},
)
data = r.json()
try:
async with httpx.AsyncClient() as client:
r = await client.post(
f"{self.url}/getreceivedinfo",
headers=self.auth,
data={"paymentHash": checking_id},
)
if r.is_error or "error" in data or data.get("status") is None:
r.raise_for_status()
data = r.json()
if r.is_error or "error" in data or data.get("status") is None:
raise Exception("error in eclair response")
statuses = {
"received": True,
"expired": False,
"pending": None,
}
return PaymentStatus(statuses.get(data["status"]["type"]))
except:
return PaymentStatus(None)
statuses = {
"received": True,
"expired": False,
"pending": None,
}
return PaymentStatus(statuses.get(data["status"]["type"]))
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
async with httpx.AsyncClient() as client:
r = await client.post(
f"{self.url}/getsentinfo",
headers=self.auth,
data={"paymentHash": checking_id},
timeout=40,
try:
async with httpx.AsyncClient() as client:
r = await client.post(
f"{self.url}/getsentinfo",
headers=self.auth,
data={"paymentHash": checking_id},
timeout=40,
)
r.raise_for_status()
data = r.json()[-1]
if r.is_error or "error" in data or data.get("status") is None:
raise Exception("error in eclair response")
fee_msat, preimage = None, None
if data["status"]["type"] == "sent":
fee_msat = -data["status"]["feesPaid"]
preimage = data["status"]["paymentPreimage"]
statuses = {
"sent": True,
"failed": False,
"pending": None,
}
return PaymentStatus(
statuses.get(data["status"]["type"]), fee_msat, preimage
)
if r.is_error:
except:
return PaymentStatus(None)
data = r.json()[-1]
if r.is_error or "error" in data or data.get("status") is None:
return PaymentStatus(None)
fee_msat, preimage = None, None
if data["status"]["type"] == "sent":
fee_msat = -data["status"]["feesPaid"]
preimage = data["status"]["paymentPreimage"]
statuses = {
"sent": True,
"failed": False,
"pending": None,
}
return PaymentStatus(statuses.get(data["status"]["type"]), fee_msat, preimage)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
while True:
try:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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