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, # VoidWallet is just a fallback that works without any actual Lightning capabilities,
# just so you can see the UI before dealing with this file. # 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: # Set one of these blocks depending on the wallet kind you chose above:
# ClicheWallet # ClicheWallet

View File

@ -64,6 +64,7 @@ async def create_invoice(
memo: str, memo: str,
description_hash: Optional[bytes] = None, description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None, unhashed_description: Optional[bytes] = None,
expiry: Optional[int] = None,
extra: Optional[Dict] = None, extra: Optional[Dict] = None,
webhook: Optional[str] = None, webhook: Optional[str] = None,
internal: Optional[bool] = False, internal: Optional[bool] = False,
@ -79,6 +80,7 @@ async def create_invoice(
memo=invoice_memo, memo=invoice_memo,
description_hash=description_hash, description_hash=description_hash,
unhashed_description=unhashed_description, unhashed_description=unhashed_description,
expiry=expiry or settings.lightning_invoice_expiry,
) )
if not ok: if not ok:
raise InvoiceFailure(error_message or "unexpected backend error.") raise InvoiceFailure(error_message or "unexpected backend error.")

View File

@ -16,7 +16,7 @@
</div> </div>
</div> </div>
<div class="row q-col-gutter-md"> <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="row">
<div class="col-12"> <div class="col-12">
<p>Active Funding<small> (Requires server restart)</small></p> <p>Active Funding<small> (Requires server restart)</small></p>
@ -30,28 +30,40 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-8">
<div class="col-12"> <div class="row q-col-gutter-md">
<p>Fee reserve</p> <div class="col-12 col-md-4">
<div class="row q-col-gutter-md"> <p>Invoice Expiry</p>
<div class="col-6"> <q-input
<q-input filled
type="number" v-model="formData.lightning_invoice_expiry"
filled label="Invoice expiry (seconds)"
v-model="formData.lnbits_reserve_fee_min" mask="#######"
label="Reserve fee in msats" >
> </q-input>
</q-input> </div>
</div> <div class="col-12 col-md-8">
<div class="col-6"> <p>Fee reserve</p>
<q-input <div class="row q-col-gutter-md">
type="number" <div class="col-6">
filled <q-input
name="lnbits_reserve_fee_percent" type="number"
v-model="formData.lnbits_reserve_fee_percent" filled
label="Reserve fee in percent" v-model="formData.lnbits_reserve_fee_min"
step="0.1" label="Reserve fee in msats"
></q-input> >
</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> </div>
</div> </div>

View File

@ -9,6 +9,7 @@
:disabled="!checkChanges" :disabled="!checkChanges"
> >
<q-tooltip v-if="checkChanges"> Save your changes </q-tooltip> <q-tooltip v-if="checkChanges"> Save your changes </q-tooltip>
<q-badge <q-badge
v-if="checkChanges" v-if="checkChanges"
color="red" color="red"
@ -17,6 +18,7 @@
style="padding: 6px; border-radius: 6px" style="padding: 6px; border-radius: 6px"
/> />
</q-btn> </q-btn>
<q-btn <q-btn
v-if="isSuperUser" v-if="isSuperUser"
label="Restart server" label="Restart server"
@ -26,6 +28,7 @@
<q-tooltip v-if="needsRestart"> <q-tooltip v-if="needsRestart">
Restart the server for changes to take effect Restart the server for changes to take effect
</q-tooltip> </q-tooltip>
<q-badge <q-badge
v-if="needsRestart" v-if="needsRestart"
color="red" color="red"
@ -34,6 +37,7 @@
style="padding: 6px; border-radius: 6px" style="padding: 6px; border-radius: 6px"
/> />
</q-btn> </q-btn>
<q-btn <q-btn
v-if="isSuperUser" v-if="isSuperUser"
label="Topup" label="Topup"
@ -42,11 +46,13 @@
> >
<q-tooltip> Add funds to a wallet. </q-tooltip> <q-tooltip> Add funds to a wallet. </q-tooltip>
</q-btn> </q-btn>
<!-- <q-btn <!-- <q-btn
label="Download Database Backup" label="Download Database Backup"
flat flat
@click="downloadBackup" @click="downloadBackup"
></q-btn> --> ></q-btn> -->
<q-btn <q-btn
flat flat
v-if="isSuperUser" v-if="isSuperUser"
@ -59,6 +65,7 @@
</q-btn> </q-btn>
</div> </div>
</div> </div>
<div class="row q-col-gutter-md justify-center"> <div class="row q-col-gutter-md justify-center">
<div class="col q-gutter-y-md"> <div class="col q-gutter-y-md">
<q-card> <q-card>
@ -70,16 +77,19 @@
label="Funding" label="Funding"
@update="val => tab = val.name" @update="val => tab = val.name"
></q-tab> ></q-tab>
<q-tab <q-tab
name="users" name="users"
label="Users" label="Users"
@update="val => tab = val.name" @update="val => tab = val.name"
></q-tab> ></q-tab>
<q-tab <q-tab
name="server" name="server"
label="Server" label="Server"
@update="val => tab = val.name" @update="val => tab = val.name"
></q-tab> ></q-tab>
<q-tab <q-tab
name="theme" name="theme"
label="Theme" label="Theme"
@ -88,6 +98,7 @@
</q-tabs> </q-tabs>
</div> </div>
</div> </div>
<q-form name="settings_form" id="settings_form"> <q-form name="settings_form" id="settings_form">
<q-tab-panels v-model="tab" animated> <q-tab-panels v-model="tab" animated>
{% include "admin/_tab_funding.html" %} {% include {% include "admin/_tab_funding.html" %} {% include
@ -98,10 +109,12 @@
</q-card> </q-card>
</div> </div>
</div> </div>
<q-dialog v-if="isSuperUser" v-model="topUpDialog.show" position="top"> <q-dialog v-if="isSuperUser" v-model="topUpDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card"> <q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form class="q-gutter-md"> <q-form class="q-gutter-md">
<p>TopUp a wallet</p> <p>TopUp a wallet</p>
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<q-input <q-input
@ -112,8 +125,10 @@
label="Wallet ID" label="Wallet ID"
hint="Use the wallet ID to topup any wallet" hint="Use the wallet ID to topup any wallet"
></q-input> ></q-input>
<br /> <br />
</div> </div>
<div class="col-12"> <div class="col-12">
<q-input <q-input
dense dense
@ -124,14 +139,15 @@
></q-input> ></q-input>
</div> </div>
</div> </div>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn label="Topup" color="primary" @click="topupWallet"></q-btn> <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> <q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div> </div>
</q-form> </q-form>
</q-card> </q-card>
</q-dialog> </q-dialog>
{% endblock %} {% block scripts %} {{ window_vars(user) }} {% endblock %} {% block scripts %} {{ window_vars(user) }}
<script> <script>
new Vue({ new Vue({
@ -173,7 +189,7 @@
} }
], ],
[ [
'CLightningWallet', 'CoreLightningWallet',
{ {
corelightning_rpc: { corelightning_rpc: {
value: null, value: null,
@ -244,15 +260,15 @@
} }
], ],
[ [
'LntxbotWallet', 'LnTipsWallet',
{ {
lntxbot_api_endpoint: { lntips_api_endpoint: {
value: null, value: null,
label: 'Endpoint' label: 'Endpoint'
}, },
lntxbot_key: { lntips_api_key: {
value: null, value: null,
label: 'Key' label: 'API Key'
} }
} }
], ],
@ -278,7 +294,7 @@
{ {
eclair_url: { eclair_url: {
value: null, value: null,
label: 'Endpoint' label: 'URL'
}, },
eclair_pass: { eclair_pass: {
value: null, value: null,
@ -333,19 +349,6 @@
label: 'Token' 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 /> <code>{"X-Api-Key": "<i>{{ wallet.inkey }}</i>"}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5> <h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code <code
>{"out": false, "amount": &lt;int&gt;, "memo": &lt;string&gt;, "unit": >{"out": false, "amount": &lt;int&gt;, "memo": &lt;string&gt;,
&lt;string&gt;, "webhook": &lt;url:string&gt;, "internal": "expiry": &lt;int&gt;, "unit": &lt;string&gt;, "webhook":
&lt;bool&gt;}</code &lt;url:string&gt;, "internal": &lt;bool&gt;}</code
> >
<h5 class="text-caption q-mt-sm q-mb-none"> <h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json) Returns 201 CREATED (application/json)
@ -62,8 +62,7 @@
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5> <h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code <code
>curl -X POST {{ request.base_url }}api/v1/payments -d '{"out": false, >curl -X POST {{ request.base_url }}api/v1/payments -d '{"out": false,
"amount": &lt;int&gt;, "memo": &lt;string&gt;, "webhook": "amount": &lt;int&gt;, "memo": &lt;string&gt;}' -H "X-Api-Key:
&lt;url:string&gt;, "unit": &lt;string&gt;}' -H "X-Api-Key:
<i>{{ wallet.inkey }}</i>" -H "Content-type: application/json"</code <i>{{ wallet.inkey }}</i>" -H "Content-type: application/json"</code
> >
</q-card-section> </q-card-section>

View File

@ -133,6 +133,7 @@ class CreateInvoiceData(BaseModel):
unit: Optional[str] = "sat" unit: Optional[str] = "sat"
description_hash: Optional[str] = None description_hash: Optional[str] = None
unhashed_description: Optional[str] = None unhashed_description: Optional[str] = None
expiry: Optional[int] = None
lnurl_callback: Optional[str] = None lnurl_callback: Optional[str] = None
lnurl_balance_check: Optional[str] = None lnurl_balance_check: Optional[str] = None
extra: Optional[dict] = None extra: Optional[dict] = None
@ -178,6 +179,7 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
memo=memo, memo=memo,
description_hash=description_hash, description_hash=description_hash,
unhashed_description=unhashed_description, unhashed_description=unhashed_description,
expiry=data.expiry,
extra=data.extra, extra=data.extra,
webhook=data.webhook, webhook=data.webhook,
internal=data.internal, internal=data.internal,

View File

@ -149,6 +149,10 @@ class BoltzExtensionSettings(LNbitsSettings):
boltz_mempool_space_url_ws: str = Field(default="wss://mempool.space") boltz_mempool_space_url_ws: str = Field(default="wss://mempool.space")
class LightningSettings(LNbitsSettings):
lightning_invoice_expiry: int = Field(default=600)
class FundingSourcesSettings( class FundingSourcesSettings(
FakeWalletFundingSource, FakeWalletFundingSource,
LNbitsFundingSource, LNbitsFundingSource,
@ -172,6 +176,7 @@ class EditableSettings(
OpsSettings, OpsSettings,
FundingSourcesSettings, FundingSourcesSettings,
BoltzExtensionSettings, BoltzExtensionSettings,
LightningSettings,
): ):
@validator( @validator(
"lnbits_admin_users", "lnbits_admin_users",
@ -217,20 +222,23 @@ class SuperUserSettings(LNbitsSettings):
default=[ default=[
"VoidWallet", "VoidWallet",
"FakeWallet", "FakeWallet",
"CLightningWallet", "CoreLightningWallet",
"LndRestWallet", "LndRestWallet",
"EclairWallet",
"LndWallet", "LndWallet",
"LntxbotWallet", "LnTipsWallet",
"LNPayWallet", "LNPayWallet",
"LNbitsWallet", "LNbitsWallet",
"OpenNodeWallet", "OpenNodeWallet",
"LnTipsWallet",
] ]
) )
class ReadOnlySettings( class ReadOnlySettings(
EnvSettings, SaaSSettings, PersistenceSettings, SuperUserSettings EnvSettings,
SaaSSettings,
PersistenceSettings,
SuperUserSettings,
): ):
lnbits_admin_ui: bool = Field(default=False) lnbits_admin_ui: bool = Field(default=False)

View File

@ -48,6 +48,7 @@ class ClicheWallet(Wallet):
memo: Optional[str] = None, memo: Optional[str] = None,
description_hash: Optional[bytes] = None, description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None, unhashed_description: Optional[bytes] = None,
**kwargs,
) -> InvoiceResponse: ) -> InvoiceResponse:
if unhashed_description or description_hash: if unhashed_description or description_hash:
description_hash_str = ( description_hash_str = (

View File

@ -83,6 +83,7 @@ class CoreLightningWallet(Wallet):
memo: Optional[str] = None, memo: Optional[str] = None,
description_hash: Optional[bytes] = None, description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None, unhashed_description: Optional[bytes] = None,
**kwargs,
) -> InvoiceResponse: ) -> InvoiceResponse:
label = "lbl{}".format(random.random()) label = "lbl{}".format(random.random())
msat: int = int(amount * 1000) msat: int = int(amount * 1000)
@ -103,6 +104,7 @@ class CoreLightningWallet(Wallet):
deschashonly=True deschashonly=True
if unhashed_description if unhashed_description
else False, # we can't pass None here else False, # we can't pass None here
expiry=kwargs.get("expiry"),
) )
if r.get("code") and r.get("code") < 0: if r.get("code") and r.get("code") < 0:

View File

@ -73,13 +73,17 @@ class EclairWallet(Wallet):
memo: Optional[str] = None, memo: Optional[str] = None,
description_hash: Optional[bytes] = None, description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None, unhashed_description: Optional[bytes] = None,
**kwargs,
) -> InvoiceResponse: ) -> InvoiceResponse:
data: Dict = {"amountMsat": amount * 1000} data: Dict = {"amountMsat": amount * 1000}
if kwargs.get("expiry"):
data["expireIn"] = kwargs["expiry"]
if description_hash: if description_hash:
data["description_hash"] = description_hash.hex() data["descriptionHash"] = description_hash.hex()
elif unhashed_description: elif unhashed_description:
data["description_hash"] = hashlib.sha256(unhashed_description).hexdigest() data["descriptionHash"] = hashlib.sha256(unhashed_description).hexdigest()
else: else:
data["description"] = memo or "" data["description"] = memo or ""
@ -162,53 +166,62 @@ class EclairWallet(Wallet):
) )
async def get_invoice_status(self, checking_id: str) -> PaymentStatus: async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
async with httpx.AsyncClient() as client: try:
r = await client.post( async with httpx.AsyncClient() as client:
f"{self.url}/getreceivedinfo", r = await client.post(
headers=self.auth, f"{self.url}/getreceivedinfo",
data={"paymentHash": checking_id}, headers=self.auth,
) data={"paymentHash": checking_id},
data = r.json() )
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) 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 def get_payment_status(self, checking_id: str) -> PaymentStatus:
async with httpx.AsyncClient() as client: try:
r = await client.post( async with httpx.AsyncClient() as client:
f"{self.url}/getsentinfo", r = await client.post(
headers=self.auth, f"{self.url}/getsentinfo",
data={"paymentHash": checking_id}, headers=self.auth,
timeout=40, 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
) )
except:
if r.is_error:
return PaymentStatus(None) 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]: async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
while True: while True:
try: try:

View File

@ -41,6 +41,7 @@ class FakeWallet(Wallet):
memo: Optional[str] = None, memo: Optional[str] = None,
description_hash: Optional[bytes] = None, description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None, unhashed_description: Optional[bytes] = None,
**kwargs,
) -> InvoiceResponse: ) -> InvoiceResponse:
data: Dict = { data: Dict = {
"out": False, "out": False,
@ -54,6 +55,7 @@ class FakeWallet(Wallet):
"expires": None, "expires": None,
"route": None, "route": None,
} }
data["expires"] = kwargs.get("expiry")
data["amount"] = amount * 1000 data["amount"] = amount * 1000
data["timestamp"] = datetime.now().timestamp() data["timestamp"] = datetime.now().timestamp()
if description_hash: if description_hash:

View File

@ -59,8 +59,11 @@ class LNbitsWallet(Wallet):
memo: Optional[str] = None, memo: Optional[str] = None,
description_hash: Optional[bytes] = None, description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None, unhashed_description: Optional[bytes] = None,
**kwargs,
) -> InvoiceResponse: ) -> InvoiceResponse:
data: Dict = {"out": False, "amount": amount} data: Dict = {"out": False, "amount": amount}
if kwargs.get("expiry"):
data["expiry"] = kwargs["expiry"]
if description_hash: if description_hash:
data["description_hash"] = description_hash.hex() data["description_hash"] = description_hash.hex()
if unhashed_description: if unhashed_description:

View File

@ -154,8 +154,11 @@ class LndWallet(Wallet):
memo: Optional[str] = None, memo: Optional[str] = None,
description_hash: Optional[bytes] = None, description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None, unhashed_description: Optional[bytes] = None,
**kwargs,
) -> InvoiceResponse: ) -> 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: if description_hash:
params["description_hash"] = description_hash params["description_hash"] = description_hash
elif unhashed_description: elif unhashed_description:

View File

@ -77,6 +77,8 @@ class LndRestWallet(Wallet):
**kwargs, **kwargs,
) -> InvoiceResponse: ) -> InvoiceResponse:
data: Dict = {"value": amount, "private": True} data: Dict = {"value": amount, "private": True}
if kwargs.get("expiry"):
data["expiry"] = kwargs["expiry"]
if description_hash: if description_hash:
data["description_hash"] = base64.b64encode(description_hash).decode( data["description_hash"] = base64.b64encode(description_hash).decode(
"ascii" "ascii"

View File

@ -119,6 +119,7 @@ class SparkWallet(Wallet):
label=label, label=label,
description=memo or "", description=memo or "",
exposeprivatechannels=True, exposeprivatechannels=True,
expiry=kwargs.get("expiry"),
) )
ok, payment_request, error_message = True, r["bolt11"], "" ok, payment_request, error_message = True, r["bolt11"], ""
except (SparkError, UnknownError) as e: except (SparkError, UnknownError) as e:

View File

@ -94,6 +94,21 @@ async def test_create_internal_invoice(client, inkey_headers_to):
return invoice 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 # check POST /api/v1/payments: make payment
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_pay_invoice(client, invoice, adminkey_headers_from): async def test_pay_invoice(client, invoice, adminkey_headers_from):