Merge branch 'main' into secure_communication

This commit is contained in:
Vlad Stan
2022-08-19 01:42:17 -04:00
committed by GitHub
102 changed files with 6105 additions and 23256 deletions
+31 -11
View File
@@ -1,6 +1,7 @@
import asyncio
import importlib
import logging
import signal
import sys
import traceback
import warnings
@@ -75,7 +76,11 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
# Only the browser sends "text/html" request
# not fail proof, but everything else get's a JSON response
if "text/html" in request.headers["accept"]:
if (
request.headers
and "accept" in request.headers
and "text/html" in request.headers["accept"]
):
return template_renderer().TemplateResponse(
"error.html",
{"request": request, "err": f"{exc.errors()} is not a valid UUID."},
@@ -101,16 +106,27 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
def check_funding_source(app: FastAPI) -> None:
@app.on_event("startup")
async def check_wallet_status():
original_sigint_handler = signal.getsignal(signal.SIGINT)
def signal_handler(signal, frame):
logger.debug(f"SIGINT received, terminating LNbits.")
sys.exit(1)
signal.signal(signal.SIGINT, signal_handler)
while True:
error_message, balance = await WALLET.status()
if not error_message:
break
logger.error(
f"The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'",
RuntimeWarning,
)
logger.info("Retrying connection to backend in 5 seconds...")
await asyncio.sleep(5)
try:
error_message, balance = await WALLET.status()
if not error_message:
break
logger.error(
f"The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'",
RuntimeWarning,
)
logger.info("Retrying connection to backend in 5 seconds...")
await asyncio.sleep(5)
except:
pass
signal.signal(signal.SIGINT, original_sigint_handler)
logger.info(
f"✔️ Backend {WALLET.__class__.__name__} connected and with a balance of {balance} msat."
)
@@ -185,7 +201,11 @@ def register_exception_handlers(app: FastAPI):
traceback.print_exception(etype, err, tb)
exc = traceback.format_exc()
if "text/html" in request.headers["accept"]:
if (
request.headers
and "accept" in request.headers
and "text/html" in request.headers["accept"]
):
return template_renderer().TemplateResponse(
"error.html", {"request": request, "err": err}
)
+1 -1
View File
@@ -216,7 +216,7 @@ def lnencode(addr, privkey):
expirybits = expirybits[5:]
data += tagged("x", expirybits)
elif k == "h":
data += tagged_bytes("h", hashlib.sha256(v.encode("utf-8")).digest())
data += tagged_bytes("h", v)
elif k == "n":
data += tagged_bytes("n", v)
else:
+3 -1
View File
@@ -4,6 +4,8 @@ from typing import Any, Dict, List, Optional
from urllib.parse import urlparse
from uuid import uuid4
from loguru import logger
from lnbits import bolt11
from lnbits.db import COCKROACH, POSTGRES, Connection
from lnbits.settings import DEFAULT_WALLET_NAME, LNBITS_ADMIN_USERS
@@ -334,7 +336,7 @@ async def delete_expired_invoices(
expiration_date = datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
if expiration_date > datetime.datetime.utcnow():
continue
logger.debug(f"Deleting expired invoice: {invoice.payment_hash}")
await (conn or db).execute(
"""
DELETE FROM apipayments
+8 -2
View File
@@ -141,19 +141,25 @@ class Payment(BaseModel):
if self.is_uncheckable:
return
logger.debug(
f"Checking {'outgoing' if self.is_out else 'incoming'} pending payment {self.checking_id}"
)
if self.is_out:
status = await WALLET.get_payment_status(self.checking_id)
else:
status = await WALLET.get_invoice_status(self.checking_id)
logger.debug(f"Status: {status}")
if self.is_out and status.failed:
logger.info(
f" - deleting outgoing failed payment {self.checking_id}: {status}"
f"Deleting outgoing failed payment {self.checking_id}: {status}"
)
await self.delete()
elif not status.pending:
logger.info(
f" - marking '{'in' if self.is_in else 'out'}' {self.checking_id} as not pending anymore: {status}"
f"Marking '{'in' if self.is_in else 'out'}' {self.checking_id} as not pending anymore: {status}"
)
await self.set_pending(status.pending)
+15 -8
View File
@@ -21,7 +21,7 @@ from lnbits.decorators import (
)
from lnbits.helpers import url_for, urlsafe_short_hash
from lnbits.requestvars import g
from lnbits.settings import FAKE_WALLET, WALLET
from lnbits.settings import FAKE_WALLET, RESERVE_FEE_MIN, RESERVE_FEE_PERCENT, WALLET
from lnbits.wallets.base import PaymentResponse, PaymentStatus
from . import db
@@ -54,6 +54,7 @@ async def create_invoice(
amount: int, # in satoshis
memo: str,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
extra: Optional[Dict] = None,
webhook: Optional[str] = None,
internal: Optional[bool] = False,
@@ -65,7 +66,10 @@ async def create_invoice(
wallet = FAKE_WALLET if internal else WALLET
ok, checking_id, payment_request, error_message = await wallet.create_invoice(
amount=amount, memo=invoice_memo, description_hash=description_hash
amount=amount,
memo=invoice_memo,
description_hash=description_hash,
unhashed_description=unhashed_description,
)
if not ok:
raise InvoiceFailure(error_message or "unexpected backend error.")
@@ -156,7 +160,7 @@ async def pay_invoice(
logger.debug("balance is too low, deleting temporary payment")
if not internal_checking_id and wallet.balance_msat > -fee_reserve_msat:
raise PaymentFailure(
f"You must reserve at least 1% ({round(fee_reserve_msat/1000)} sat) to cover potential routing fees."
f"You must reserve at least ({round(fee_reserve_msat/1000)} sat) to cover potential routing fees."
)
raise PermissionError("Insufficient balance.")
@@ -182,7 +186,7 @@ async def pay_invoice(
payment_request, fee_reserve_msat
)
logger.debug(f"backend: pay_invoice finished {temp_id}")
if payment.checking_id:
if payment.ok and payment.checking_id:
logger.debug(f"creating final payment {payment.checking_id}")
async with db.connect() as conn:
await create_payment(
@@ -196,7 +200,7 @@ async def pay_invoice(
logger.debug(f"deleting temporary payment {temp_id}")
await delete_payment(temp_id, conn=conn)
else:
logger.debug(f"backend payment failed, no checking_id {temp_id}")
logger.debug(f"backend payment failed")
async with db.connect() as conn:
logger.debug(f"deleting temporary payment {temp_id}")
await delete_payment(temp_id, conn=conn)
@@ -337,13 +341,16 @@ async def perform_lnurlauth(
)
async def check_invoice_status(
async def check_transaction_status(
wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
) -> PaymentStatus:
payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn)
if not payment:
return PaymentStatus(None)
status = await WALLET.get_invoice_status(payment.checking_id)
if payment.is_out:
status = await WALLET.get_payment_status(payment.checking_id)
else:
status = await WALLET.get_invoice_status(payment.checking_id)
if not payment.pending:
return status
if payment.is_out and status.failed:
@@ -359,4 +366,4 @@ async def check_invoice_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:
return max(2000, int(amount_msat * 0.01))
return max(int(RESERVE_FEE_MIN), int(amount_msat * RESERVE_FEE_PERCENT / 100.0))
+11 -1
View File
@@ -668,7 +668,17 @@ new Vue({
})
},
exportCSV: function () {
LNbits.utils.exportCSV(this.paymentsTable.columns, this.payments)
// status is important for export but it is not in paymentsTable
// because it is manually added with payment detail link and icons
// and would cause duplication in the list
let columns = this.paymentsTable.columns
columns.unshift({
name: 'pending',
align: 'left',
label: 'Pending',
field: 'pending'
})
LNbits.utils.exportCSV(columns, this.payments)
}
},
watch: {
+26 -11
View File
@@ -1,7 +1,7 @@
import asyncio
import binascii
import hashlib
import json
from binascii import unhexlify
from http import HTTPStatus
from io import BytesIO
from typing import Dict, List, Optional, Tuple, Union
@@ -48,7 +48,7 @@ from ..crud import (
from ..services import (
InvoiceFailure,
PaymentFailure,
check_invoice_status,
check_transaction_status,
create_invoice,
pay_invoice,
perform_lnurlauth,
@@ -123,7 +123,7 @@ async def api_payments(
offset=offset,
)
for payment in pendingPayments:
await check_invoice_status(
await check_transaction_status(
wallet_id=payment.wallet_id, payment_hash=payment.payment_hash
)
return await get_payments(
@@ -141,6 +141,7 @@ class CreateInvoiceData(BaseModel):
memo: Optional[str] = None
unit: Optional[str] = "sat"
description_hash: Optional[str] = None
unhashed_description: Optional[str] = None
lnurl_callback: Optional[str] = None
lnurl_balance_check: Optional[str] = None
extra: Optional[dict] = None
@@ -151,10 +152,28 @@ class CreateInvoiceData(BaseModel):
async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
if data.description_hash:
description_hash = unhexlify(data.description_hash)
try:
description_hash = binascii.unhexlify(data.description_hash)
except binascii.Error:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="'description_hash' must be a valid hex string",
)
unhashed_description = b""
memo = ""
elif data.unhashed_description:
try:
unhashed_description = binascii.unhexlify(data.unhashed_description)
except binascii.Error:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="'unhashed_description' must be a valid hex string",
)
description_hash = b""
memo = ""
else:
description_hash = b""
unhashed_description = b""
memo = data.memo or LNBITS_SITE_TITLE
if data.unit == "sat":
amount = int(data.amount)
@@ -170,6 +189,7 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
amount=amount,
memo=memo,
description_hash=description_hash,
unhashed_description=unhashed_description,
extra=data.extra,
webhook=data.webhook,
internal=data.internal,
@@ -186,11 +206,6 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
if data.lnurl_callback:
if data.lnurl_balance_check is not None:
await save_balance_check(wallet.id, data.lnurl_balance_check)
else:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="lnurl_balance_check not set.",
)
async with httpx.AsyncClient() as client:
try:
@@ -267,7 +282,7 @@ async def api_payments_create(
return await api_payments_create_invoice(invoiceData, wallet.wallet)
else:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
status_code=HTTPStatus.UNAUTHORIZED,
detail="Invoice (or Admin) key required.",
)
@@ -407,7 +422,7 @@ async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
)
await check_invoice_status(payment.wallet_id, payment_hash)
await check_transaction_status(payment.wallet_id, payment_hash)
payment = await get_standalone_payment(
payment_hash, wallet_id=wallet.id if wallet else None
)
+3 -1
View File
@@ -148,7 +148,9 @@ async def wallet(
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
)
logger.debug(f"Access wallet {wallet_name}{'of user '+ user.id if user else ''}")
logger.debug(
f"Access {'user '+ user.id + ' ' if user else ''} {'wallet ' + wallet_name if wallet_name else ''}"
)
userwallet = user.get_wallet(wallet_id) # type: ignore
if not userwallet:
return template_renderer().TemplateResponse(
+22 -5
View File
@@ -130,10 +130,13 @@ async def get_key_type(
# 2: invalid
pathname = r["path"].split("/")[1]
if not api_key_header and not api_key_query:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
token = api_key_header or api_key_query
token = api_key_header if api_key_header else api_key_query
if not token:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
detail="Invoice (or Admin) key required.",
)
try:
admin_checker = WalletAdminKeyChecker(api_key=token)
@@ -180,7 +183,14 @@ async def require_admin_key(
api_key_header: str = Security(api_key_header), # type: ignore
api_key_query: str = Security(api_key_query), # type: ignore
):
token = api_key_header if api_key_header else api_key_query
token = api_key_header or api_key_query
if not token:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
detail="Admin key required.",
)
wallet = await get_key_type(r, token)
@@ -199,7 +209,14 @@ async def require_invoice_key(
api_key_header: str = Security(api_key_header), # type: ignore
api_key_query: str = Security(api_key_query), # type: ignore
):
token = api_key_header if api_key_header else api_key_query
token = api_key_header or api_key_query
if not token:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
detail="Invoice (or Admin) key required.",
)
wallet = await get_key_type(r, token)
+1 -1
View File
@@ -73,7 +73,7 @@ async def lnurl_callback(
wallet_id=cp.wallet,
amount=int(amount_received / 1000),
memo=cp.lnurl_title,
description_hash=(
unhashed_description=(
LnurlPayMetadata(json.dumps([["text/plain", str(cp.lnurl_title)]]))
).encode("utf-8"),
extra={"tag": "copilot", "copilotid": cp.id, "comment": comment},
+19
View File
@@ -0,0 +1,19 @@
# Invoices
## Create invoices that you can send to your client to pay online over Lightning.
This extension allows users to create "traditional" invoices (not in the lightning sense) that contain one or more line items. Line items are denominated in a user-configurable fiat currency. Each invoice contains one or more payments up to the total of the invoice. Each invoice creates a public link that can be shared with a customer that they can use to (partially or in full) pay the invoice.
## Usage
1. Create an invoice by clicking "NEW INVOICE"\
![create new invoice](https://imgur.com/a/Dce3wrr.png)
2. Fill the options for your INVOICE
- select the wallet
- select the fiat currency the invoice will be denominated in
- select a status for the invoice (default is draft)
- enter a company name, first name, last name, email, phone & address (optional)
- add one or more line items
- enter a name & price for each line item
3. You can then use share your invoice link with your customer to receive payment\
![invoice link](https://imgur.com/a/L0JOj4T.png)
+36
View File
@@ -0,0 +1,36 @@
import asyncio
from fastapi import APIRouter
from starlette.staticfiles import StaticFiles
from lnbits.db import Database
from lnbits.helpers import template_renderer
from lnbits.tasks import catch_everything_and_restart
db = Database("ext_invoices")
invoices_static_files = [
{
"path": "/invoices/static",
"app": StaticFiles(directory="lnbits/extensions/invoices/static"),
"name": "invoices_static",
}
]
invoices_ext: APIRouter = APIRouter(prefix="/invoices", tags=["invoices"])
def invoices_renderer():
return template_renderer(["lnbits/extensions/invoices/templates"])
from .tasks import wait_for_paid_invoices
def invoices_start():
loop = asyncio.get_event_loop()
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
from .views import * # noqa
from .views_api import * # noqa
+6
View File
@@ -0,0 +1,6 @@
{
"name": "Invoices",
"short_description": "Create invoices for your clients.",
"icon": "request_quote",
"contributors": ["leesalminen"]
}
+206
View File
@@ -0,0 +1,206 @@
from typing import List, Optional, Union
from lnbits.helpers import urlsafe_short_hash
from . import db
from .models import (
CreateInvoiceData,
CreateInvoiceItemData,
CreatePaymentData,
Invoice,
InvoiceItem,
Payment,
UpdateInvoiceData,
UpdateInvoiceItemData,
)
async def get_invoice(invoice_id: str) -> Optional[Invoice]:
row = await db.fetchone(
"SELECT * FROM invoices.invoices WHERE id = ?", (invoice_id,)
)
return Invoice.from_row(row) if row else None
async def get_invoice_items(invoice_id: str) -> List[InvoiceItem]:
rows = await db.fetchall(
f"SELECT * FROM invoices.invoice_items WHERE invoice_id = ?", (invoice_id,)
)
return [InvoiceItem.from_row(row) for row in rows]
async def get_invoice_item(item_id: str) -> InvoiceItem:
row = await db.fetchone(
"SELECT * FROM invoices.invoice_items WHERE id = ?", (item_id,)
)
return InvoiceItem.from_row(row) if row else None
async def get_invoice_total(items: List[InvoiceItem]) -> int:
return sum(item.amount for item in items)
async def get_invoices(wallet_ids: Union[str, List[str]]) -> List[Invoice]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM invoices.invoices WHERE wallet IN ({q})", (*wallet_ids,)
)
return [Invoice.from_row(row) for row in rows]
async def get_invoice_payments(invoice_id: str) -> List[Payment]:
rows = await db.fetchall(
f"SELECT * FROM invoices.payments WHERE invoice_id = ?", (invoice_id,)
)
return [Payment.from_row(row) for row in rows]
async def get_invoice_payment(payment_id: str) -> Payment:
row = await db.fetchone(
"SELECT * FROM invoices.payments WHERE id = ?", (payment_id,)
)
return Payment.from_row(row) if row else None
async def get_payments_total(payments: List[Payment]) -> int:
return sum(item.amount for item in payments)
async def create_invoice_internal(wallet_id: str, data: CreateInvoiceData) -> Invoice:
invoice_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO invoices.invoices (id, wallet, status, currency, company_name, first_name, last_name, email, phone, address)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
invoice_id,
wallet_id,
data.status,
data.currency,
data.company_name,
data.first_name,
data.last_name,
data.email,
data.phone,
data.address,
),
)
invoice = await get_invoice(invoice_id)
assert invoice, "Newly created invoice couldn't be retrieved"
return invoice
async def create_invoice_items(
invoice_id: str, data: List[CreateInvoiceItemData]
) -> List[InvoiceItem]:
for item in data:
item_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO invoices.invoice_items (id, invoice_id, description, amount)
VALUES (?, ?, ?, ?)
""",
(
item_id,
invoice_id,
item.description,
int(item.amount * 100),
),
)
invoice_items = await get_invoice_items(invoice_id)
return invoice_items
async def update_invoice_internal(wallet_id: str, data: UpdateInvoiceData) -> Invoice:
await db.execute(
"""
UPDATE invoices.invoices
SET wallet = ?, currency = ?, status = ?, company_name = ?, first_name = ?, last_name = ?, email = ?, phone = ?, address = ?
WHERE id = ?
""",
(
wallet_id,
data.currency,
data.status,
data.company_name,
data.first_name,
data.last_name,
data.email,
data.phone,
data.address,
data.id,
),
)
invoice = await get_invoice(data.id)
assert invoice, "Newly updated invoice couldn't be retrieved"
return invoice
async def update_invoice_items(
invoice_id: str, data: List[UpdateInvoiceItemData]
) -> List[InvoiceItem]:
updated_items = []
for item in data:
if item.id:
updated_items.append(item.id)
await db.execute(
"""
UPDATE invoices.invoice_items
SET description = ?, amount = ?
WHERE id = ?
""",
(item.description, int(item.amount * 100), item.id),
)
placeholders = ",".join("?" for i in range(len(updated_items)))
if not placeholders:
placeholders = "?"
updated_items = ("skip",)
await db.execute(
f"""
DELETE FROM invoices.invoice_items
WHERE invoice_id = ?
AND id NOT IN ({placeholders})
""",
(
invoice_id,
*tuple(updated_items),
),
)
for item in data:
if not item.id:
await create_invoice_items(invoice_id=invoice_id, data=[item])
invoice_items = await get_invoice_items(invoice_id)
return invoice_items
async def create_invoice_payment(invoice_id: str, amount: int) -> Payment:
payment_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO invoices.payments (id, invoice_id, amount)
VALUES (?, ?, ?)
""",
(
payment_id,
invoice_id,
amount,
),
)
payment = await get_invoice_payment(payment_id)
assert payment, "Newly created payment couldn't be retrieved"
return payment
+55
View File
@@ -0,0 +1,55 @@
async def m001_initial_invoices(db):
# STATUS COLUMN OPTIONS: 'draft', 'open', 'paid', 'canceled'
await db.execute(
f"""
CREATE TABLE invoices.invoices (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
currency TEXT NOT NULL,
company_name TEXT DEFAULT NULL,
first_name TEXT DEFAULT NULL,
last_name TEXT DEFAULT NULL,
email TEXT DEFAULT NULL,
phone TEXT DEFAULT NULL,
address TEXT DEFAULT NULL,
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
"""
)
await db.execute(
f"""
CREATE TABLE invoices.invoice_items (
id TEXT PRIMARY KEY,
invoice_id TEXT NOT NULL,
description TEXT NOT NULL,
amount INTEGER NOT NULL,
FOREIGN KEY(invoice_id) REFERENCES {db.references_schema}invoices(id)
);
"""
)
await db.execute(
f"""
CREATE TABLE invoices.payments (
id TEXT PRIMARY KEY,
invoice_id TEXT NOT NULL,
amount INT NOT NULL,
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
FOREIGN KEY(invoice_id) REFERENCES {db.references_schema}invoices(id)
);
"""
)
+104
View File
@@ -0,0 +1,104 @@
from enum import Enum
from sqlite3 import Row
from typing import List, Optional
from fastapi.param_functions import Query
from pydantic import BaseModel
class InvoiceStatusEnum(str, Enum):
draft = "draft"
open = "open"
paid = "paid"
canceled = "canceled"
class CreateInvoiceItemData(BaseModel):
description: str
amount: float = Query(..., ge=0.01)
class CreateInvoiceData(BaseModel):
status: InvoiceStatusEnum = InvoiceStatusEnum.draft
currency: str
company_name: Optional[str]
first_name: Optional[str]
last_name: Optional[str]
email: Optional[str]
phone: Optional[str]
address: Optional[str]
items: List[CreateInvoiceItemData]
class Config:
use_enum_values = True
class UpdateInvoiceItemData(BaseModel):
id: Optional[str]
description: str
amount: float = Query(..., ge=0.01)
class UpdateInvoiceData(BaseModel):
id: str
wallet: str
status: InvoiceStatusEnum = InvoiceStatusEnum.draft
currency: str
company_name: Optional[str]
first_name: Optional[str]
last_name: Optional[str]
email: Optional[str]
phone: Optional[str]
address: Optional[str]
items: List[UpdateInvoiceItemData]
class Invoice(BaseModel):
id: str
wallet: str
status: InvoiceStatusEnum = InvoiceStatusEnum.draft
currency: str
company_name: Optional[str]
first_name: Optional[str]
last_name: Optional[str]
email: Optional[str]
phone: Optional[str]
address: Optional[str]
time: int
class Config:
use_enum_values = True
@classmethod
def from_row(cls, row: Row) -> "Invoice":
return cls(**dict(row))
class InvoiceItem(BaseModel):
id: str
invoice_id: str
description: str
amount: int
class Config:
orm_mode = True
@classmethod
def from_row(cls, row: Row) -> "InvoiceItem":
return cls(**dict(row))
class Payment(BaseModel):
id: str
invoice_id: str
amount: int
time: int
@classmethod
def from_row(cls, row: Row) -> "Payment":
return cls(**dict(row))
class CreatePaymentData(BaseModel):
invoice_id: str
amount: int
@@ -0,0 +1,65 @@
#invoicePage>.row:first-child>.col-md-6 {
display: flex;
}
#invoicePage>.row:first-child>.col-md-6>.q-card {
flex: 1;
}
#invoicePage .clear {
margin-bottom: 25px;
}
#printQrCode {
display: none;
}
@media (min-width: 1024px) {
#invoicePage>.row:first-child>.col-md-6:first-child>div {
margin-right: 5px;
}
#invoicePage>.row:first-child>.col-md-6:nth-child(2)>div {
margin-left: 5px;
}
}
@media print {
* {
color: black !important;
}
header, button, #payButtonContainer {
display: none !important;
}
main, .q-page-container {
padding-top: 0px !important;
}
.q-card {
box-shadow: none !important;
border: 1px solid black;
}
.q-item {
padding: 5px;
}
.q-card__section {
padding: 5px;
}
#printQrCode {
display: block;
}
p {
margin-bottom: 0px !important;
}
#invoicePage .clear {
margin-bottom: 10px !important;
}
}
+51
View File
@@ -0,0 +1,51 @@
import asyncio
import json
from lnbits.core.models import Payment
from lnbits.helpers import urlsafe_short_hash
from lnbits.tasks import internal_invoice_queue, register_invoice_listener
from .crud import (
create_invoice_payment,
get_invoice,
get_invoice_items,
get_invoice_payments,
get_invoice_total,
get_payments_total,
update_invoice_internal,
)
async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue)
while True:
payment = await invoice_queue.get()
await on_invoice_paid(payment)
async def on_invoice_paid(payment: Payment) -> None:
if payment.extra.get("tag") != "invoices":
# not relevant
return
invoice_id = payment.extra.get("invoice_id")
payment = await create_invoice_payment(
invoice_id=invoice_id, amount=payment.extra.get("famount")
)
invoice = await get_invoice(invoice_id)
invoice_items = await get_invoice_items(invoice_id)
invoice_total = await get_invoice_total(invoice_items)
invoice_payments = await get_invoice_payments(invoice_id)
payments_total = await get_payments_total(invoice_payments)
if payments_total >= invoice_total:
invoice.status = "paid"
await update_invoice_internal(invoice.wallet, invoice)
return
@@ -0,0 +1,153 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-expansion-item group="api" dense expand-separator label="List Invoices">
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span> /invoices/api/v1/invoices</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</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">
Returns 200 OK (application/json)
</h5>
<code>[&lt;invoice_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url }}invoices/api/v1/invoices -H
"X-Api-Key: &lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Fetch Invoice">
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/invoices/api/v1/invoice/{invoice_id}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</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">
Returns 200 OK (application/json)
</h5>
<code>{invoice_object}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url
}}invoices/api/v1/invoice/{invoice_id} -H "X-Api-Key:
&lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Create Invoice">
<q-card>
<q-card-section>
<code
><span class="text-blue">POST</span> /invoices/api/v1/invoice</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</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">
Returns 200 OK (application/json)
</h5>
<code>{invoice_object}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url }}invoices/api/v1/invoice -H
"X-Api-Key: &lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Update Invoice">
<q-card>
<q-card-section>
<code
><span class="text-blue">POST</span>
/invoices/api/v1/invoice/{invoice_id}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</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">
Returns 200 OK (application/json)
</h5>
<code>{invoice_object}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url
}}invoices/api/v1/invoice/{invoice_id} -H "X-Api-Key:
&lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Create Invoice Payment"
>
<q-card>
<q-card-section>
<code
><span class="text-blue">POST</span>
/invoices/api/v1/invoice/{invoice_id}/payments</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>{payment_object}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url
}}invoices/api/v1/invoice/{invoice_id}/payments -H "X-Api-Key:
&lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Check Invoice Payment Status"
>
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/invoices/api/v1/invoice/{invoice_id}/payments/{payment_hash}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url
}}invoices/api/v1/invoice/{invoice_id}/payments/{payment_hash} -H
"X-Api-Key: &lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>
@@ -0,0 +1,571 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn unelevated color="primary" @click="formDialog.show = true"
>New Invoice</q-btn
>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Invoices</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
</div>
</div>
<q-table
dense
flat
:data="invoices"
row-key="id"
:columns="invoicesTable.columns"
:pagination.sync="invoicesTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
unelevated
dense
size="xs"
icon="edit"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="showEditModal(props.row)"
></q-btn>
<q-btn
unelevated
dense
size="xs"
icon="launch"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="'pay/' + props.row.id"
target="_blank"
></q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">
{{SITE_TITLE}} Invoices extension
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "invoices/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="saveInvoice" class="q-gutter-md">
<q-select
filled
dense
emit-value
v-model="formDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
></q-select>
<q-select
filled
dense
emit-value
v-model="formDialog.data.currency"
:options="currencyOptions"
label="Currency *"
></q-select>
<q-select
filled
dense
emit-value
v-model="formDialog.data.status"
:options="['draft', 'open', 'paid', 'canceled']"
label="Status *"
></q-select>
<q-input
filled
dense
v-model.trim="formDialog.data.company_name"
label="Company Name"
placeholder="LNBits Labs"
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.first_name"
label="First Name"
placeholder="Satoshi"
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.last_name"
label="Last Name"
placeholder="Nakamoto"
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.email"
label="Email"
placeholder="satoshi@gmail.com"
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.phone"
label="Phone"
placeholder="+81 (012)-345-6789"
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.address"
label="Address"
placeholder="1600 Pennsylvania Ave."
type="textarea"
></q-input>
<q-list bordered separator>
<q-item
clickable
v-ripple
v-for="(item, index) in formDialog.invoiceItems"
:key="index"
>
<q-item-section>
<q-input
filled
dense
label="Item"
placeholder="Jelly Beans"
v-model="formDialog.invoiceItems[index].description"
></q-input>
</q-item-section>
<q-item-section>
<q-input
filled
dense
label="Amount"
placeholder="4.20"
v-model="formDialog.invoiceItems[index].amount"
></q-input>
</q-item-section>
<q-item-section side>
<q-btn
unelevated
dense
size="xs"
icon="delete"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="formDialog.invoiceItems.splice(index, 1)"
></q-btn>
</q-item-section>
</q-item>
<q-item clickable v-ripple>
<q-btn flat icon="add" @click="formDialog.invoiceItems.push({})">
Add Line Item
</q-btn>
</q-item>
</q-list>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="formDialog.data.wallet == null || formDialog.data.currency == null"
type="submit"
v-if="typeof formDialog.data.id == 'undefined'"
>Create Invoice</q-btn
>
<q-btn
unelevated
color="primary"
:disable="formDialog.data.wallet == null || formDialog.data.currency == null"
type="submit"
v-if="typeof formDialog.data.id !== 'undefined'"
>Save Invoice</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
var mapInvoice = function (obj) {
obj.time = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
return obj
}
var mapInvoiceItems = function (obj) {
obj.amount = parseFloat(obj.amount / 100).toFixed(2)
return obj
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
invoices: [],
currencyOptions: [
'USD',
'EUR',
'GBP',
'AED',
'AFN',
'ALL',
'AMD',
'ANG',
'AOA',
'ARS',
'AUD',
'AWG',
'AZN',
'BAM',
'BBD',
'BDT',
'BGN',
'BHD',
'BIF',
'BMD',
'BND',
'BOB',
'BRL',
'BSD',
'BTN',
'BWP',
'BYN',
'BZD',
'CAD',
'CDF',
'CHF',
'CLF',
'CLP',
'CNH',
'CNY',
'COP',
'CRC',
'CUC',
'CUP',
'CVE',
'CZK',
'DJF',
'DKK',
'DOP',
'DZD',
'EGP',
'ERN',
'ETB',
'EUR',
'FJD',
'FKP',
'GBP',
'GEL',
'GGP',
'GHS',
'GIP',
'GMD',
'GNF',
'GTQ',
'GYD',
'HKD',
'HNL',
'HRK',
'HTG',
'HUF',
'IDR',
'ILS',
'IMP',
'INR',
'IQD',
'IRR',
'IRT',
'ISK',
'JEP',
'JMD',
'JOD',
'JPY',
'KES',
'KGS',
'KHR',
'KMF',
'KPW',
'KRW',
'KWD',
'KYD',
'KZT',
'LAK',
'LBP',
'LKR',
'LRD',
'LSL',
'LYD',
'MAD',
'MDL',
'MGA',
'MKD',
'MMK',
'MNT',
'MOP',
'MRO',
'MUR',
'MVR',
'MWK',
'MXN',
'MYR',
'MZN',
'NAD',
'NGN',
'NIO',
'NOK',
'NPR',
'NZD',
'OMR',
'PAB',
'PEN',
'PGK',
'PHP',
'PKR',
'PLN',
'PYG',
'QAR',
'RON',
'RSD',
'RUB',
'RWF',
'SAR',
'SBD',
'SCR',
'SDG',
'SEK',
'SGD',
'SHP',
'SLL',
'SOS',
'SRD',
'SSP',
'STD',
'SVC',
'SYP',
'SZL',
'THB',
'TJS',
'TMT',
'TND',
'TOP',
'TRY',
'TTD',
'TWD',
'TZS',
'UAH',
'UGX',
'USD',
'UYU',
'UZS',
'VEF',
'VES',
'VND',
'VUV',
'WST',
'XAF',
'XAG',
'XAU',
'XCD',
'XDR',
'XOF',
'XPD',
'XPF',
'XPT',
'YER',
'ZAR',
'ZMW',
'ZWL'
],
invoicesTable: {
columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{name: 'status', align: 'left', label: 'Status', field: 'status'},
{name: 'time', align: 'left', label: 'Created', field: 'time'},
{name: 'wallet', align: 'left', label: 'Wallet', field: 'wallet'},
{
name: 'currency',
align: 'left',
label: 'Currency',
field: 'currency'
},
{
name: 'company_name',
align: 'left',
label: 'Company Name',
field: 'company_name'
},
{
name: 'first_name',
align: 'left',
label: 'First Name',
field: 'first_name'
},
{
name: 'last_name',
align: 'left',
label: 'Last Name',
field: 'last_name'
},
{name: 'email', align: 'left', label: 'Email', field: 'email'},
{name: 'phone', align: 'left', label: 'Phone', field: 'phone'},
{name: 'address', align: 'left', label: 'Address', field: 'address'}
],
pagination: {
rowsPerPage: 10
}
},
formDialog: {
show: false,
data: {},
invoiceItems: []
}
}
},
methods: {
closeFormDialog: function () {
this.formDialog.data = {}
this.formDialog.invoiceItems = []
},
showEditModal: function (obj) {
this.formDialog.data = obj
this.formDialog.show = true
this.getInvoice(obj.id)
},
getInvoice: function (invoice_id) {
var self = this
LNbits.api
.request('GET', '/invoices/api/v1/invoice/' + invoice_id)
.then(function (response) {
self.formDialog.invoiceItems = response.data.items.map(function (
obj
) {
return mapInvoiceItems(obj)
})
})
},
getInvoices: function () {
var self = this
LNbits.api
.request(
'GET',
'/invoices/api/v1/invoices?all_wallets=true',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.invoices = response.data.map(function (obj) {
return mapInvoice(obj)
})
})
},
saveInvoice: function () {
var data = this.formDialog.data
data.items = this.formDialog.invoiceItems
var self = this
LNbits.api
.request(
'POST',
'/invoices/api/v1/invoice' + (data.id ? '/' + data.id : ''),
_.findWhere(this.g.user.wallets, {id: this.formDialog.data.wallet})
.inkey,
data
)
.then(function (response) {
if (!data.id) {
self.invoices.push(mapInvoice(response.data))
} else {
self.getInvoices()
}
self.formDialog.invoiceItems = []
self.formDialog.show = false
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
deleteTPoS: function (tposId) {
var self = this
var tpos = _.findWhere(this.tposs, {id: tposId})
LNbits.utils
.confirmDialog('Are you sure you want to delete this TPoS?')
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/tpos/api/v1/tposs/' + tposId,
_.findWhere(self.g.user.wallets, {id: tpos.wallet}).adminkey
)
.then(function (response) {
self.tposs = _.reject(self.tposs, function (obj) {
return obj.id == tposId
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
},
exportCSV: function () {
LNbits.utils.exportCSV(this.invoicesTable.columns, this.invoices)
}
},
created: function () {
if (this.g.user.wallets.length) {
this.getInvoices()
}
}
})
</script>
{% endblock %}
@@ -0,0 +1,430 @@
{% extends "public.html" %} {% block toolbar_title %} Invoice
<q-btn
flat
dense
size="md"
@click.prevent="urlDialog.show = true"
icon="share"
color="white"
></q-btn>
<q-btn
flat
dense
size="md"
@click.prevent="printInvoice()"
icon="print"
color="white"
></q-btn>
{% endblock %} {% from "macros.jinja" import window_vars with context %} {%
block page %}
<link rel="stylesheet" href="/invoices/static/css/pay.css" />
<div id="invoicePage">
<div class="row q-gutter-y-md">
<div class="col-md-6 col-sm-12 col-xs-12">
<q-card>
<q-card-section>
<p>
<b>Invoice</b>
</p>
<q-list bordered separator>
<q-item clickable v-ripple>
<q-item-section><b>ID</b></q-item-section>
<q-item-section style="word-break: break-all"
>{{ invoice_id }}</q-item-section
>
</q-item>
<q-item clickable v-ripple>
<q-item-section><b>Created At</b></q-item-section>
<q-item-section
>{{ datetime.utcfromtimestamp(invoice.time).strftime('%Y-%m-%d
%H:%M') }}</q-item-section
>
</q-item>
<q-item clickable v-ripple>
<q-item-section><b>Status</b></q-item-section>
<q-item-section>
<span>
<q-badge color=""> {{ invoice.status }} </q-badge>
</span>
</q-item-section>
</q-item>
<q-item clickable v-ripple>
<q-item-section><b>Total</b></q-item-section>
<q-item-section>
{{ "{:0,.2f}".format(invoice_total / 100) }} {{ invoice.currency
}}
</q-item-section>
</q-item>
<q-item clickable v-ripple>
<q-item-section><b>Paid</b></q-item-section>
<q-item-section>
<div class="row" style="align-items: center">
<div class="col-sm-6">
{{ "{:0,.2f}".format(payments_total / 100) }} {{
invoice.currency }}
</div>
<div class="col-sm-6" id="payButtonContainer">
{% if payments_total < invoice_total %}
<q-btn
unelevated
color="primary"
@click="formDialog.show = true"
v-if="status == 'open'"
>
Pay Invoice
</q-btn>
{% endif %}
</div>
</div>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
</q-card>
</div>
<div class="col-md-6 col-sm-12 col-xs-12">
<q-card>
<q-card-section>
<p>
<b>Bill To</b>
</p>
<q-list bordered separator>
<q-item clickable v-ripple>
<q-item-section><b>Company Name</b></q-item-section>
<q-item-section>{{ invoice.company_name }}</q-item-section>
</q-item>
<q-item clickable v-ripple>
<q-item-section><b>Name</b></q-item-section>
<q-item-section
>{{ invoice.first_name }} {{ invoice.last_name
}}</q-item-section
>
</q-item>
<q-item clickable v-ripple>
<q-item-section><b>Address</b></q-item-section>
<q-item-section>{{ invoice.address }}</q-item-section>
</q-item>
<q-item clickable v-ripple>
<q-item-section><b>Email</b></q-item-section>
<q-item-section>{{ invoice.email }}</q-item-section>
</q-item>
<q-item clickable v-ripple>
<q-item-section><b>Phone</b></q-item-section>
<q-item-section>{{ invoice.phone }}</q-item-section>
</q-item>
</q-list>
</q-card-section>
</q-card>
</div>
</div>
<div class="clear"></div>
<div class="row">
<div class="col-12 col-md">
<q-card>
<q-card-section>
<p>
<b>Items</b>
</p>
<q-list bordered separator>
{% if invoice_items %}
<q-item clickable v-ripple>
<q-item-section><b>Item</b></q-item-section>
<q-item-section side><b>Amount</b></q-item-section>
</q-item>
{% endif %} {% for item in invoice_items %}
<q-item clickable v-ripple>
<q-item-section><b>{{item.description}}</b></q-item-section>
<q-item-section side>
{{ "{:0,.2f}".format(item.amount / 100) }} {{ invoice.currency
}}
</q-item-section>
</q-item>
{% endfor %} {% if not invoice_items %} No Invoice Items {% endif %}
</q-list>
</q-card-section>
</q-card>
</div>
</div>
<div class="clear"></div>
<div class="row">
<div class="col-12 col-md">
<q-card>
<q-card-section>
<p>
<b>Payments</b>
</p>
<q-list bordered separator>
{% if invoice_payments %}
<q-item clickable v-ripple>
<q-item-section><b>Date</b></q-item-section>
<q-item-section side><b>Amount</b></q-item-section>
</q-item>
{% endif %} {% for item in invoice_payments %}
<q-item clickable v-ripple>
<q-item-section
><b
>{{ datetime.utcfromtimestamp(item.time).strftime('%Y-%m-%d
%H:%M') }}</b
></q-item-section
>
<q-item-section side>
{{ "{:0,.2f}".format(item.amount / 100) }} {{ invoice.currency
}}
</q-item-section>
</q-item>
{% endfor %} {% if not invoice_payments %} No Invoice Payments {%
endif %}
</q-list>
</q-card-section>
</q-card>
</div>
</div>
<div class="clear"></div>
<div class="row q-gutter-y-md q-gutter-md" id="printQrCode">
<div class="col-12 col-md">
<div class="text-center">
<p><b>Scan to View & Pay Online!</b></p>
<qrcode
value="{{ request.url }}"
:options="{width: 200}"
class="rounded-borders"
></qrcode>
</div>
</div>
</div>
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="createPayment" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="formDialog.data.payment_amount"
:rules="[val => val >= 0.01 || 'Minimum amount is 0.01']"
min="0.01"
label="Payment Amount"
placeholder="4.20"
>
<template v-slot:append>
<span style="font-size: 12px"> {{ invoice.currency }} </span>
</template>
</q-input>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="formDialog.data.payment_amount == null"
type="submit"
>Create Payment</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog
v-model="qrCodeDialog.show"
position="top"
@hide="closeQrCodeDialog"
>
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card text-center">
<a :href="'lightning:' + qrCodeDialog.data.payment_request">
<q-responsive :ratio="1" class="q-mx-xs">
<qrcode
:value="qrCodeDialog.data.payment_request"
:options="{width: 400}"
class="rounded-borders"
></qrcode>
</q-responsive>
</a>
<br />
<q-btn
outline
color="grey"
@click="copyText('lightning:' + qrCodeDialog.data.payment_request, 'Invoice copied to clipboard!')"
>Copy Invoice</q-btn
>
</q-card>
</q-dialog>
<q-dialog v-model="urlDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
<qrcode
value="{{ request.url }}"
:options="{width: 400}"
class="rounded-borders"
></qrcode>
</q-responsive>
<div class="text-center q-mb-xl">
<p style="word-break: break-all">{{ request.url }}</p>
</div>
<div class="row q-mt-lg">
<q-btn
outline
color="grey"
@click="copyText('{{ request.url }}', 'Invoice Pay URL copied to clipboard!')"
>Copy URL</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %}
<script>
var mapInvoice = function (obj) {
obj.time = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
return obj
}
var mapInvoiceItems = function (obj) {
obj.amount = parseFloat(obj.amount / 100).toFixed(2)
return obj
}
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
invoice_id: '{{ invoice.id }}',
wallet: '{{ invoice.wallet }}',
currency: '{{ invoice.currency }}',
status: '{{ invoice.status }}',
qrCodeDialog: {
data: {
payment_request: null,
},
show: false,
},
formDialog: {
data: {
payment_amount: parseFloat({{invoice_total - payments_total}} / 100).toFixed(2)
},
show: false,
},
urlDialog: {
show: false,
},
}
},
methods: {
printInvoice: function() {
window.print()
},
closeFormDialog: function() {
this.formDialog.show = false
},
closeQrCodeDialog: function() {
this.qrCodeDialog.show = false
},
createPayment: function () {
var self = this
var qrCodeDialog = this.qrCodeDialog
var formDialog = this.formDialog
var famount = parseInt(formDialog.data.payment_amount * 100)
axios
.post('/invoices/api/v1/invoice/' + this.invoice_id + '/payments', null, {
params: {
famount: famount,
}
})
.then(function (response) {
formDialog.show = false
formDialog.data = {}
qrCodeDialog.data = response.data
qrCodeDialog.show = true
console.log(qrCodeDialog.data)
qrCodeDialog.dismissMsg = self.$q.notify({
timeout: 0,
message: 'Waiting for payment...'
})
qrCodeDialog.paymentChecker = setInterval(function () {
axios
.get(
'/invoices/api/v1/invoice/' +
self.invoice_id +
'/payments/' +
response.data.payment_hash
)
.then(function (res) {
if (res.data.paid) {
clearInterval(qrCodeDialog.paymentChecker)
qrCodeDialog.dismissMsg()
qrCodeDialog.show = false
setTimeout(function () {
window.location.reload()
}, 500)
}
})
}, 3000)
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
},
computed: {
statusBadgeColor: function() {
switch(this.status) {
case 'draft':
return 'gray'
break
case 'open':
return 'blue'
break
case 'paid':
return 'green'
break
case 'canceled':
return 'red'
break
}
},
},
created: function () {
}
})
</script>
{% endblock %}
+59
View File
@@ -0,0 +1,59 @@
from datetime import datetime
from http import HTTPStatus
from fastapi import FastAPI, Request
from fastapi.params import Depends
from fastapi.templating import Jinja2Templates
from starlette.exceptions import HTTPException
from starlette.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from . import invoices_ext, invoices_renderer
from .crud import (
get_invoice,
get_invoice_items,
get_invoice_payments,
get_invoice_total,
get_payments_total,
)
templates = Jinja2Templates(directory="templates")
@invoices_ext.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
return invoices_renderer().TemplateResponse(
"invoices/index.html", {"request": request, "user": user.dict()}
)
@invoices_ext.get("/pay/{invoice_id}", response_class=HTMLResponse)
async def index(request: Request, invoice_id: str):
invoice = await get_invoice(invoice_id)
if not invoice:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Invoice does not exist."
)
invoice_items = await get_invoice_items(invoice_id)
invoice_total = await get_invoice_total(invoice_items)
invoice_payments = await get_invoice_payments(invoice_id)
payments_total = await get_payments_total(invoice_payments)
return invoices_renderer().TemplateResponse(
"invoices/pay.html",
{
"request": request,
"invoice_id": invoice_id,
"invoice": invoice.dict(),
"invoice_items": invoice_items,
"invoice_total": invoice_total,
"invoice_payments": invoice_payments,
"payments_total": payments_total,
"datetime": datetime,
},
)
+136
View File
@@ -0,0 +1,136 @@
from http import HTTPStatus
from fastapi import Query
from fastapi.params import Depends
from loguru import logger
from starlette.exceptions import HTTPException
from lnbits.core.crud import get_user
from lnbits.core.services import create_invoice
from lnbits.core.views.api import api_payment
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
from . import invoices_ext
from .crud import (
create_invoice_internal,
create_invoice_items,
get_invoice,
get_invoice_items,
get_invoice_payments,
get_invoice_total,
get_invoices,
get_payments_total,
update_invoice_internal,
update_invoice_items,
)
from .models import CreateInvoiceData, UpdateInvoiceData
@invoices_ext.get("/api/v1/invoices", status_code=HTTPStatus.OK)
async def api_invoices(
all_wallets: bool = Query(None), wallet: WalletTypeInfo = Depends(get_key_type)
):
wallet_ids = [wallet.wallet.id]
if all_wallets:
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
return [invoice.dict() for invoice in await get_invoices(wallet_ids)]
@invoices_ext.get("/api/v1/invoice/{invoice_id}", status_code=HTTPStatus.OK)
async def api_invoice(invoice_id: str):
invoice = await get_invoice(invoice_id)
if not invoice:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Invoice does not exist."
)
invoice_items = await get_invoice_items(invoice_id)
invoice_payments = await get_invoice_payments(invoice_id)
payments_total = await get_payments_total(invoice_payments)
invoice_dict = invoice.dict()
invoice_dict["items"] = invoice_items
invoice_dict["payments"] = payments_total
return invoice_dict
@invoices_ext.post("/api/v1/invoice", status_code=HTTPStatus.CREATED)
async def api_invoice_create(
data: CreateInvoiceData, wallet: WalletTypeInfo = Depends(get_key_type)
):
invoice = await create_invoice_internal(wallet_id=wallet.wallet.id, data=data)
items = await create_invoice_items(invoice_id=invoice.id, data=data.items)
invoice_dict = invoice.dict()
invoice_dict["items"] = items
return invoice_dict
@invoices_ext.post("/api/v1/invoice/{invoice_id}", status_code=HTTPStatus.OK)
async def api_invoice_update(
data: UpdateInvoiceData,
invoice_id: str,
wallet: WalletTypeInfo = Depends(get_key_type),
):
invoice = await update_invoice_internal(wallet_id=wallet.wallet.id, data=data)
items = await update_invoice_items(invoice_id=invoice.id, data=data.items)
invoice_dict = invoice.dict()
invoice_dict["items"] = items
return invoice_dict
@invoices_ext.post(
"/api/v1/invoice/{invoice_id}/payments", status_code=HTTPStatus.CREATED
)
async def api_invoices_create_payment(
famount: int = Query(..., ge=1), invoice_id: str = None
):
invoice = await get_invoice(invoice_id)
invoice_items = await get_invoice_items(invoice_id)
invoice_total = await get_invoice_total(invoice_items)
invoice_payments = await get_invoice_payments(invoice_id)
payments_total = await get_payments_total(invoice_payments)
if payments_total + famount > invoice_total:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Amount exceeds invoice due."
)
if not invoice:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Invoice does not exist."
)
price_in_sats = await fiat_amount_as_satoshis(famount / 100, invoice.currency)
try:
payment_hash, payment_request = await create_invoice(
wallet_id=invoice.wallet,
amount=price_in_sats,
memo=f"Payment for invoice {invoice_id}",
extra={"tag": "invoices", "invoice_id": invoice_id, "famount": famount},
)
except Exception as e:
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
return {"payment_hash": payment_hash, "payment_request": payment_request}
@invoices_ext.get(
"/api/v1/invoice/{invoice_id}/payments/{payment_hash}", status_code=HTTPStatus.OK
)
async def api_invoices_check_payment(invoice_id: str, payment_hash: str):
invoice = await get_invoice(invoice_id)
if not invoice:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Invoice does not exist."
)
try:
status = await api_payment(payment_hash)
except Exception as exc:
logger.error(exc)
return {"paid": False}
return status
+1 -1
View File
@@ -90,7 +90,7 @@ async def lnurl_callback(
wallet_id=ls.wallet,
amount=int(amount_received / 1000),
memo=await track.fullname(),
description_hash=(await track.lnurlpay_metadata()).encode("utf-8"),
unhashed_description=(await track.lnurlpay_metadata()).encode("utf-8"),
extra={"tag": "livestream", "track": track.id, "comment": comment},
)
@@ -13,7 +13,7 @@
Charge people for using your domain name...<br />
<a
href="https://github.com/lnbits/lnbits/tree/master/lnbits/extensions/lnaddress"
href="https://github.com/lnbits/lnbits-legend/tree/main/lnbits/extensions/lnaddress"
>More details</a
>
<br />
+2 -2
View File
@@ -6,7 +6,7 @@ from fastapi.params import Depends, Query
from starlette.exceptions import HTTPException
from lnbits.core.crud import get_user
from lnbits.core.services import check_invoice_status, create_invoice
from lnbits.core.services import check_transaction_status, create_invoice
from lnbits.decorators import WalletTypeInfo, get_key_type
from lnbits.extensions.lnaddress.models import CreateAddress, CreateDomain
@@ -229,7 +229,7 @@ async def api_address_send_address(payment_hash):
address = await get_address(payment_hash)
domain = await get_domain(address.domain)
try:
status = await check_invoice_status(domain.wallet, payment_hash)
status = await check_transaction_status(domain.wallet, payment_hash)
is_paid = not status.pending
except Exception as e:
return {"paid": False, "error": str(e)}
+1 -1
View File
@@ -205,7 +205,7 @@ async def lnurl_callback(
wallet_id=device.wallet,
amount=lnurldevicepayment.sats / 1000,
memo=device.title,
description_hash=(await device.lnurlpay_metadata()).encode("utf-8"),
unhashed_description=(await device.lnurlpay_metadata()).encode("utf-8"),
extra={"tag": "PoS"},
)
lnurldevicepayment = await update_lnurldevicepayment(
+1 -1
View File
@@ -87,7 +87,7 @@ async def api_lnurl_callback(request: Request, link_id):
wallet_id=link.wallet,
amount=int(amount_received / 1000),
memo=link.description,
description_hash=link.lnurlpay_metadata.encode("utf-8"),
unhashed_description=link.lnurlpay_metadata.encode("utf-8"),
extra={
"tag": "lnurlp",
"link": link.id,
@@ -296,16 +296,17 @@
<q-btn
outline
color="grey"
icon="link"
@click="copyText(qrCodeDialog.data.pay_url, 'Link copied to clipboard!')"
>Shareable link</q-btn
>
><q-tooltip>Copy sharable link</q-tooltip>
</q-btn>
<q-btn
outline
color="grey"
icon="nfc"
@click="writeNfcTag(qrCodeDialog.data.lnurl)"
:disable="nfcTagWriting"
>
><q-tooltip>Write to NFC</q-tooltip>
</q-btn>
<q-btn
outline
@@ -314,7 +315,8 @@
type="a"
:href="qrCodeDialog.data.print_url"
target="_blank"
></q-btn>
><q-tooltip>Print</q-tooltip></q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
+3 -3
View File
@@ -96,7 +96,7 @@ async def api_link_create_or_update(
data.min *= data.fiat_base_multiplier
data.max *= data.fiat_base_multiplier
if data.success_url is not None and data.success_url.startswith("https://"):
if data.success_url is not None and not data.success_url.startswith("https://"):
raise HTTPException(
detail="Success URL must be secure https://...",
status_code=HTTPStatus.BAD_REQUEST,
@@ -121,7 +121,7 @@ async def api_link_create_or_update(
return {**link.dict(), "lnurl": link.lnurl(request)}
@lnurlp_ext.delete("/api/v1/links/{link_id}")
@lnurlp_ext.delete("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(get_key_type)):
link = await get_pay_link(link_id)
@@ -136,7 +136,7 @@ async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(get_key_type
)
await delete_pay_link(link_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
return {"success": True}
@lnurlp_ext.get("/api/v1/rate/{currency}", status_code=HTTPStatus.OK)
+1 -1
View File
@@ -73,7 +73,7 @@ async def lnurl_callback(request: Request, item_id: int):
wallet_id=shop.wallet,
amount=int(amount_received / 1000),
memo=item.name,
description_hash=(await item.lnurlpay_metadata()).encode("utf-8"),
unhashed_description=(await item.lnurlpay_metadata()).encode("utf-8"),
extra={"tag": "offlineshop", "item": item.id},
)
except Exception as exc:
+2 -2
View File
@@ -4,7 +4,7 @@ from fastapi import Depends, Query
from starlette.exceptions import HTTPException
from lnbits.core.crud import get_user, get_wallet
from lnbits.core.services import check_invoice_status, create_invoice
from lnbits.core.services import check_transaction_status, create_invoice
from lnbits.decorators import WalletTypeInfo, get_key_type
from . import paywall_ext
@@ -87,7 +87,7 @@ async def api_paywal_check_invoice(
status_code=HTTPStatus.NOT_FOUND, detail="Paywall does not exist."
)
try:
status = await check_invoice_status(paywall.wallet, payment_hash)
status = await check_transaction_status(paywall.wallet, payment_hash)
is_paid = not status.pending
except Exception:
return {"paid": False}
+1 -1
View File
@@ -77,7 +77,7 @@ async def api_lnurlp_callback(
wallet_id=link.wallet,
amount=int(amount_received / 1000),
memo="Satsdice bet",
description_hash=link.lnurlpay_metadata.encode("utf-8"),
unhashed_description=link.lnurlpay_metadata.encode("utf-8"),
extra={"tag": "satsdice", "link": link.id, "comment": "comment"},
)
@@ -232,7 +232,7 @@
<q-btn
outline
color="grey"
icon="share"
icon="link"
@click="copyText(qrCodeDialog.data.pay_url, 'Link copied to clipboard!')"
><q-tooltip>Copy shareable link</q-tooltip></q-btn
>
@@ -13,7 +13,7 @@
Charge people for using your subdomain name...<br />
<a
href="https://github.com/lnbits/lnbits/tree/master/lnbits/extensions/subdomains"
href="https://github.com/lnbits/lnbits-legend/tree/main/lnbits/extensions/subdomains"
>More details</a
>
<br />
+2 -2
View File
@@ -5,7 +5,7 @@ from fastapi.params import Depends
from starlette.exceptions import HTTPException
from lnbits.core.crud import get_user
from lnbits.core.services import check_invoice_status, create_invoice
from lnbits.core.services import check_transaction_status, create_invoice
from lnbits.decorators import WalletTypeInfo, get_key_type
from lnbits.extensions.subdomains.models import CreateDomain, CreateSubdomain
@@ -161,7 +161,7 @@ async def api_subdomain_make_subdomain(domain_id, data: CreateSubdomain):
async def api_subdomain_send_subdomain(payment_hash):
subdomain = await get_subdomain(payment_hash)
try:
status = await check_invoice_status(subdomain.wallet, payment_hash)
status = await check_transaction_status(subdomain.wallet, payment_hash)
is_paid = not status.pending
except Exception:
return {"paid": False}
+1 -1
View File
@@ -76,10 +76,10 @@ async def get_tipjars(wallet_id: str) -> Optional[list]:
async def delete_tipjar(tipjar_id: int) -> None:
"""Delete a TipJar and all corresponding Tips"""
await db.execute("DELETE FROM tipjar.TipJars WHERE id = ?", (tipjar_id,))
rows = await db.fetchall("SELECT * FROM tipjar.Tips WHERE tipjar = ?", (tipjar_id,))
for row in rows:
await delete_tip(row["id"])
await db.execute("DELETE FROM tipjar.TipJars WHERE id = ?", (tipjar_id,))
async def get_tip(tip_id: str) -> Optional[Tip]:
+4
View File
@@ -23,3 +23,7 @@ class TPoS(BaseModel):
@classmethod
def from_row(cls, row: Row) -> "TPoS":
return cls(**dict(row))
class PayLnurlWData(BaseModel):
lnurl: str
+102 -2
View File
@@ -14,7 +14,7 @@
<div class="row justify-center full-width">
<div class="col-12 col-sm-8 col-md-6 col-lg-4 text-center">
<h3 class="q-mb-md">{% raw %}{{ famount }}{% endraw %}</h3>
<h5 class="q-mt-none">
<h5 class="q-mt-none q-mb-sm">
{% raw %}{{ fsat }}{% endraw %} <small>sat</small>
</h5>
</div>
@@ -174,6 +174,13 @@
>
{% endraw %}
</h5>
<q-btn
outline
color="grey"
icon="nfc"
@click="readNfcTag()"
:disable="nfcTagReading"
></q-btn>
</div>
<div class="row q-mt-lg">
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
@@ -281,6 +288,7 @@
exchangeRate: null,
stack: [],
tipAmount: 0.0,
nfcTagReading: false,
invoiceDialog: {
show: false,
data: null,
@@ -356,7 +364,7 @@
this.showInvoice()
},
submitForm: function () {
if (this.tip_options) {
if (this.tip_options.length) {
this.showTipModal()
} else {
this.showInvoice()
@@ -410,6 +418,98 @@
LNbits.utils.notifyApiError(error)
})
},
readNfcTag: function () {
try {
const self = this
if (typeof NDEFReader == 'undefined') {
throw {
toString: function () {
return 'NFC not supported on this device or browser.'
}
}
}
const ndef = new NDEFReader()
const readerAbortController = new AbortController()
readerAbortController.signal.onabort = event => {
console.log('All NFC Read operations have been aborted.')
}
this.nfcTagReading = true
this.$q.notify({
message: 'Tap your NFC tag to pay this invoice with LNURLw.'
})
return ndef.scan({signal: readerAbortController.signal}).then(() => {
ndef.onreadingerror = () => {
self.nfcTagReading = false
this.$q.notify({
type: 'negative',
message: 'There was an error reading this NFC tag.'
})
readerAbortController.abort()
}
ndef.onreading = ({message}) => {
//Decode NDEF data from tag
const textDecoder = new TextDecoder('utf-8')
const record = message.records.find(el => {
const payload = textDecoder.decode(el.data)
return payload.toUpperCase().indexOf('LNURL') !== -1
})
const lnurl = textDecoder.decode(record.data)
//User feedback, show loader icon
self.nfcTagReading = false
self.payInvoice(lnurl, readerAbortController)
this.$q.notify({
type: 'positive',
message: 'NFC tag read successfully.'
})
}
})
} catch (error) {
this.nfcTagReading = false
this.$q.notify({
type: 'negative',
message: error
? error.toString()
: 'An unexpected error has occurred.'
})
}
},
payInvoice: function (lnurl, readerAbortController) {
const self = this
return axios
.post(
'/tpos/api/v1/tposs/' +
self.tposId +
'/invoices/' +
self.invoiceDialog.data.payment_request +
'/pay',
{
lnurl: lnurl
}
)
.then(response => {
if (!response.data.success) {
this.$q.notify({
type: 'negative',
message: response.data.detail
})
}
readerAbortController.abort()
})
},
getRates: function () {
var self = this
axios.get('https://api.opennode.co/v1/rates').then(function (response) {
+63 -1
View File
@@ -1,7 +1,9 @@
from http import HTTPStatus
import httpx
from fastapi import Query
from fastapi.params import Depends
from lnurl import decode as decode_lnurl
from loguru import logger
from starlette.exceptions import HTTPException
@@ -12,7 +14,7 @@ from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
from . import tpos_ext
from .crud import create_tpos, delete_tpos, get_tpos, get_tposs
from .models import CreateTposData
from .models import CreateTposData, PayLnurlWData
@tpos_ext.get("/api/v1/tposs", status_code=HTTPStatus.OK)
@@ -79,6 +81,66 @@ async def api_tpos_create_invoice(
return {"payment_hash": payment_hash, "payment_request": payment_request}
@tpos_ext.post(
"/api/v1/tposs/{tpos_id}/invoices/{payment_request}/pay", status_code=HTTPStatus.OK
)
async def api_tpos_pay_invoice(
lnurl_data: PayLnurlWData, payment_request: str = None, tpos_id: str = None
):
tpos = await get_tpos(tpos_id)
if not tpos:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist."
)
lnurl = (
lnurl_data.lnurl.replace("lnurlw://", "")
.replace("lightning://", "")
.replace("LIGHTNING://", "")
.replace("lightning:", "")
.replace("LIGHTNING:", "")
)
if lnurl.lower().startswith("lnurl"):
lnurl = decode_lnurl(lnurl)
else:
lnurl = "https://" + lnurl
async with httpx.AsyncClient() as client:
try:
r = await client.get(lnurl, follow_redirects=True)
if r.is_error:
lnurl_response = {"success": False, "detail": "Error loading"}
else:
resp = r.json()
if resp["tag"] != "withdrawRequest":
lnurl_response = {"success": False, "detail": "Wrong tag type"}
else:
r2 = await client.get(
resp["callback"],
follow_redirects=True,
params={
"k1": resp["k1"],
"pr": payment_request,
},
)
resp2 = r2.json()
if r2.is_error:
lnurl_response = {
"success": False,
"detail": "Error loading callback",
}
elif resp2["status"] == "ERROR":
lnurl_response = {"success": False, "detail": resp2["reason"]}
else:
lnurl_response = {"success": True, "detail": resp2}
except (httpx.ConnectError, httpx.RequestError):
lnurl_response = {"success": False, "detail": "Unexpected error occurred"}
return lnurl_response
@tpos_ext.get(
"/api/v1/tposs/{tpos_id}/invoices/{payment_hash}", status_code=HTTPStatus.OK
)
@@ -111,7 +111,7 @@
<q-td colspan="100%">
<div class="row items-center q-mt-md q-mb-lg">
<div class="col-2 q-pr-lg"></div>
<div class="col-4 q-pr-lg">
<div class="col-2 q-pr-lg">
<q-btn
unelevated
dense
@@ -123,6 +123,16 @@
QR Code</q-btn
>
</div>
<div class="col-2 q-pr-lg">
<q-btn
outline
color="grey"
icon="content_copy"
@click="copyText(props.row.address)"
class="q-ml-sm"
>Copy</q-btn
>
</div>
<div class="col-2 q-pr-lg">
<q-btn
outline
@@ -74,6 +74,16 @@ async function addressList(path) {
satBtc(val, showUnit = true) {
return satOrBtc(val, showUnit, this.satsDenominated)
},
// todo: bad. base.js not present in custom components
copyText: function (text, message, position) {
var notify = this.$q.notify
Quasar.utils.copyToClipboard(text).then(function () {
notify({
message: message || 'Copied to clipboard!',
position: position || 'bottom'
})
})
},
getWalletName: function (walletId) {
const wallet = (this.accounts || []).find(wl => wl.id === walletId)
return wallet ? wallet.title : 'unknown'
@@ -123,7 +123,6 @@
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="hwwConfigAndConnect" class="q-gutter-md">
<span>Enter Config</span>
<q-input
filled
dense
@@ -131,6 +130,7 @@
label="Name (optional)"
></q-input>
<q-separator></q-separator>
<serial-port-config
ref="serialPortConfig"
:config="hww.config"
@@ -1,16 +1,17 @@
<div>
<q-card>
<div class="row items-center no-wrap q-mb-md">
<div class="col-2 q-ml-lg">
<div class="col-md-2 col-xs-4 q-ml-lg">
<q-btn unelevated @click="show = true" color="primary" icon="settings">
</q-btn>
</div>
<div class="col-8">
<div class="col-md-8 col-xs-4">
<div class="row justify-center q-gutter-x-md items-center">
<div class="text-h3">{{satBtc(total)}}</div>
<div :class="{'text-h4': $q.screen.gt.md}">{{satBtc(total)}}</div>
</div>
</div>
<div class="col-2 float-right">
<div class="col-md-2 col-xs-4 q-pr-lg">
<slot name="serial"></slot>
</div>
</div>
@@ -58,124 +58,10 @@ async function walletConfig(path) {
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
deriveSecretKey: async function (privateKey, publicKey) {
return window.crypto.subtle.deriveKey(
{
name: 'ECDH',
public: publicKey
},
privateKey,
{
name: 'AES-GCM',
length: 256
},
true,
['encrypt', 'decrypt']
)
},
old: async function () {
// const alicesKeyPair = await window.crypto.subtle.generateKey(
// {
// name: 'ECDH',
// namedCurve: 'P-256'
// },
// true,
// ['deriveKey']
// )
// const bobsKeyPair = await window.crypto.subtle.generateKey(
// {
// name: 'ECDH',
// namedCurve: 'P-256'
// },
// true,
// ['deriveKey']
// )
// console.log('### alicesKeyPair', alicesKeyPair)
// console.log('### alicesKeyPair.privateKey', alicesKeyPair.privateKey)
// console.log('### alicesKeyPair.publicKey', alicesKeyPair.publicKey)
// const alicesPriveKey = await window.crypto.subtle.exportKey(
// 'jwk',
// alicesKeyPair.privateKey
// )
// console.log('### alicesPriveKey', alicesPriveKey)
// const alicesPriveKeyBase64 = toStdBase64(alicesPriveKey.d)
// console.log(
// '### alicesPriveKeyBase64',
// alicesPriveKeyBase64,
// base64ToHex(alicesPriveKeyBase64)
// )
// const bobPublicKey = await window.crypto.subtle.exportKey(
// 'raw',
// bobsKeyPair.publicKey
// )
// console.log('### bobPublicKey hex', buf2hex(bobPublicKey))
// const sharedSecret01 = await this.deriveSecretKey(alicesKeyPair.privateKey, bobsKeyPair.publicKey)
// console.log('### sharedSecret01', sharedSecret01)
// const sharedSecret01Raw = await window.crypto.subtle.exportKey('jwk', sharedSecret01)
// console.log('### sharedSecret01Raw', sharedSecret01Raw)
// const sharedSecret02 = await this.deriveSecretKey(bobsKeyPair.privateKey, alicesKeyPair.publicKey)
// console.log('### sharedSecret02', sharedSecret02)
// const sharedSecret02Raw = await window.crypto.subtle.exportKey('jwk', sharedSecret02)
// console.log('### sharedSecret02Raw', sharedSecret02Raw)
// const sharedSecret = nobleSecp256k1.getSharedSecret(
// alicesPriveKey,
// buf2hex(bobPublicKey)
// )
// console.log('###', getSharedSecret)
},
importSecretKey: async function (rawKey) {
return window.crypto.subtle.importKey('raw', rawKey, 'AES-GCM', true, [
'encrypt',
'decrypt'
])
}
},
created: async function () {
await this.getConfig()
// ### in: 6bc1bee22e409f96e93d7e117393172aae2d8a571e03ac9c9eb76fac45af8e5130c81c46a35ce411e5fbc1191a0a52eff69f2445df4f9b17ad2b417be66c3710
// ### in2: 073059e23605fe508919d892501974905f8b5e13728db63b1b7b9326951abbe4f722c104bc82a4bcf3f1e15ede8ab7d5eb00be8c46271e1f65867f984e9cb5f1
// const alicesPrivateKey =
// '359A8CA1418C49DD26DC7D92C789AC33347F64C6B7789C666098805AF3CC60E5'
// const bobsPrivateKey =
// 'AB52F1F981F639BD83F884703BC690B10DB709FF48806680A0D3FBC6475E6093'
// const alicesPublicKey = nobleSecp256k1.Point.fromPrivateKey(
// alicesPrivateKey
// )
// console.log('### alicesPublicKey', alicesPublicKey.toHex())
// const bobsPublicKey = nobleSecp256k1.Point.fromPrivateKey(bobsPrivateKey)
// console.log('### bobsPublicKey', bobsPublicKey.toHex())
// const sharedSecret = nobleSecp256k1.getSharedSecret(
// alicesPrivateKey,
// bobsPublicKey
// )
// console.log('### sharedSecret naked', sharedSecret)
// console.log(
// '### sharedSecret a',
// nobleSecp256k1.utils.bytesToHex(sharedSecret)
// )
// console.log(
// '### sharedSecret b',
// nobleSecp256k1.Point.fromHex(sharedSecret)
// )
// console.log(
// '### sharedSecret b',
// nobleSecp256k1.Point.fromHex(sharedSecret).toHex(true)
// )
// const alicesPrivateKeyBytes = nobleSecp256k1.utils.hexToBytes(
// alicesPrivateKey
// )
// const x = await this.importSecretKey(alicesPrivateKeyBytes)
// console.log('### x', x)
}
})
}
@@ -2,7 +2,7 @@
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<div class="col-4">
<q-btn-dropdown
split
unelevated
@@ -30,9 +30,8 @@
</q-list>
</q-btn-dropdown>
</div>
<div class="col-auto q-pr-lg"></div>
<div class="col-auto q-pl-lg">
<div class="col-4 q-pl-lg"></div>
<div class="col-4 q-pl-lg">
<q-input
borderless
dense
@@ -19,6 +19,7 @@ const COMMAND_CHECK_PAIRING = '/check-pairing'
const DEFAULT_RECEIVE_GAP_LIMIT = 20
const PAIRING_CONTROL_TEXT = 'lnbits'
const blockTimeToDate = blockTime =>
blockTime ? moment(blockTime * 1000).format('LLL') : ''
@@ -13,6 +13,7 @@
:network="config.network"
:sats-denominated="config.sats_denominated"
@signed:psbt="updateSignedPsbt"
class="q-pr-lg float-right"
></serial-signer>
</template>
</wallet-config>
@@ -33,7 +34,7 @@
{% raw %}
<q-card>
<div class="row q-pt-sm q-pb-sm items-center no-wrap q-mb-md">
<div class="col-3 q-pl-md">
<div class="col-md-3 col-sm-5 q-pl-md">
<q-btn
unelevated
class="btn-full"
@@ -43,14 +44,14 @@
>Scan Blockchain</q-btn
>
</div>
<div class="col-6">
<div class="col-md-6 col-sm-2 q-pl-md">
<q-spinner
v-if="scan.scanning == true"
color="primary"
size="2.55em"
></q-spinner>
</div>
<div class="col-3 q-pr-md">
<div class="col-md-3 col-sm-5 q-pr-md">
<q-btn
v-if="!showPayment"
unelevated
@@ -170,14 +171,25 @@
></qrcode>
</q-responsive>
<p v-if="currentAddress">
<q-btn
flat
dense
size="ms"
icon="content_copy"
@click="copyText(props.row.address)"
class="q-ml-sm"
></q-btn>
{{ currentAddress.address }}
<q-btn
flat
dense
size="ms"
icon="launch"
type="a"
:href="mempoolHostname + '/address/' + currentAddress.address"
:href="'https://' + mempoolHostname + '/address/' + currentAddress.address"
target="_blank"
></q-btn>
</p>
@@ -239,5 +251,6 @@
<script src="{{ url_for('watchonly_static', path='components/serial-port-config/serial-port-config.js') }}"></script>
<script src="{{ url_for('watchonly_static', path='js/crypto/noble-secp256k1.js') }}"></script>
<script src="{{ url_for('watchonly_static', path='js/crypto/aes.js') }}"></script>
<script src="{{ url_for('watchonly_static', path='js/index.js') }}"></script>
{% endblock %}
+10 -2
View File
@@ -70,7 +70,7 @@ new Vue({
show: false,
data: {
is_unique: true,
use_custom: true,
use_custom: false,
title: 'Vouchers',
min_withdrawable: 0,
wait_time: 1
@@ -125,7 +125,6 @@ new Vue({
var link = _.findWhere(this.withdrawLinks, {id: linkId})
this.qrCodeDialog.data = _.clone(link)
console.log(this.qrCodeDialog.data)
this.qrCodeDialog.data.url =
window.location.protocol + '//' + window.location.host
this.qrCodeDialog.show = true
@@ -140,6 +139,11 @@ new Vue({
id: this.formDialog.data.wallet
})
var data = _.omit(this.formDialog.data, 'wallet')
if (!data.use_custom) {
data.custom_url = null
}
if (data.use_custom && !data?.custom_url) {
data.custom_url = CUSTOM_URL
}
@@ -168,6 +172,10 @@ new Vue({
data.title = 'vouchers'
data.is_unique = true
if (!data.use_custom) {
data.custom_url = null
}
if (data.use_custom && !data?.custom_url) {
data.custom_url = '/static/images/default_voucher.png'
}
@@ -241,7 +241,7 @@
v-model="formDialog.data.custom_url"
type="text"
label="Custom design .png (optional)"
hint="Enter a URL if you want to use a custom design or leave blank for showing only the QR"
hint="Enter a URL if you want to use a custom design or leave blank for LNbits designed vouchers!"
></q-input>
<q-list>
<q-item tag="label" class="rounded-borders">
@@ -353,7 +353,7 @@
v-model="simpleformDialog.data.custom_url"
type="text"
label="Custom design .png (optional)"
hint="Enter a URL if you want to use a custom design or leave blank for showing only the QR"
hint="Enter a URL if you want to use a custom design or leave blank for LNbits designed vouchers!"
></q-input>
<div class="row q-mt-lg">
@@ -418,16 +418,18 @@
<q-btn
outline
color="grey"
icon="link"
@click="copyText(qrCodeDialog.data.withdraw_url, 'Link copied to clipboard!')"
>Shareable link</q-btn
>
><q-tooltip>Copy sharable link</q-tooltip>
</q-btn>
<q-btn
outline
color="grey"
icon="nfc"
@click="writeNfcTag(qrCodeDialog.data.lnurl)"
:disable="nfcTagWriting"
></q-btn>
><q-tooltip>Write to NFC</q-tooltip></q-btn
>
<q-btn
outline
color="grey"
@@ -435,7 +437,8 @@
type="a"
:href="qrCodeDialog.data.print_url"
target="_blank"
></q-btn>
><q-tooltip>Print</q-tooltip></q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
+2 -2
View File
@@ -113,7 +113,7 @@ async def api_link_create_or_update(
return {**link.dict(), **{"lnurl": link.lnurl(req)}}
@withdraw_ext.delete("/api/v1/links/{link_id}")
@withdraw_ext.delete("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(require_admin_key)):
link = await get_withdraw_link(link_id)
@@ -128,7 +128,7 @@ async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(require_admi
)
await delete_withdraw_link(link_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
return {"success": True}
@withdraw_ext.get("/api/v1/links/{the_hash}/{lnurl_id}", status_code=HTTPStatus.OK)
+40 -9
View File
@@ -1,18 +1,49 @@
import time
import click
import uvicorn
from lnbits.settings import HOST, PORT
@click.command()
@click.option("--port", default="5000", help="Port to run LNBits on")
@click.option("--host", default="127.0.0.1", help="Host to run LNBits on")
def main(port, host):
@click.command(
context_settings=dict(
ignore_unknown_options=True,
allow_extra_args=True,
)
)
@click.option("--port", default=PORT, help="Port to listen on")
@click.option("--host", default=HOST, help="Host to run LNBits on")
@click.option("--ssl-keyfile", default=None, help="Path to SSL keyfile")
@click.option("--ssl-certfile", default=None, help="Path to SSL certificate")
@click.pass_context
def main(ctx, port: int, host: str, ssl_keyfile: str, ssl_certfile: str):
"""Launched with `poetry run lnbits` at root level"""
uvicorn.run("lnbits.__main__:app", port=port, host=host)
# this beautiful beast parses all command line arguments and passes them to the uvicorn server
d = dict()
for a in ctx.args:
item = a.split("=")
if len(item) > 1: # argument like --key=value
print(a, item)
d[item[0].strip("--").replace("-", "_")] = (
int(item[1]) # need to convert to int if it's a number
if item[1].isdigit()
else item[1]
)
else:
d[a.strip("--")] = True # argument like --key
config = uvicorn.Config(
"lnbits.__main__:app",
port=port,
host=host,
ssl_keyfile=ssl_keyfile,
ssl_certfile=ssl_certfile,
**d
)
server = uvicorn.Server(config)
server.run()
if __name__ == "__main__":
main()
# def main():
# """Launched with `poetry run start` at root level"""
# uvicorn.run("lnbits.__main__:app")
+2
View File
@@ -55,6 +55,8 @@ FAKE_WALLET = getattr(wallets_module, "FakeWallet")()
DEFAULT_WALLET_NAME = env.str("LNBITS_DEFAULT_WALLET_NAME", default="LNbits wallet")
PREFER_SECURE_URLS = env.bool("LNBITS_FORCE_HTTPS", default=True)
RESERVE_FEE_MIN = env.int("LNBITS_RESERVE_FEE_MIN", default=2000)
RESERVE_FEE_PERCENT = env.float("LNBITS_RESERVE_FEE_PERCENT", default=1.0)
SERVICE_FEE = env.float("LNBITS_SERVICE_FEE", default=0.0)
try:
+5
View File
@@ -179,6 +179,11 @@ Vue.component('lnbits-extension-list', {
Vue.component('lnbits-payment-details', {
props: ['payment'],
data: function () {
return {
LNBITS_DENOMINATION: LNBITS_DENOMINATION
}
},
template: `
<div class="q-py-md" style="text-align: left">
<div class="row justify-center q-mb-md">
+1
View File
@@ -6,6 +6,7 @@ from .cln import CoreLightningWallet as CLightningWallet
from .eclair import EclairWallet
from .fake import FakeWallet
from .lnbits import LNbitsWallet
from .lndgrpc import LndWallet
from .lndrest import LndRestWallet
from .lnpay import LNPayWallet
from .lntxbot import LntxbotWallet
+10 -3
View File
@@ -46,12 +46,19 @@ class ClicheWallet(Wallet):
amount: int,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
) -> InvoiceResponse:
if description_hash:
description_hash_hashed = hashlib.sha256(description_hash).hexdigest()
if unhashed_description or description_hash:
description_hash_str = (
description_hash.hex()
if description_hash
else hashlib.sha256(unhashed_description).hexdigest()
if unhashed_description
else None
)
ws = create_connection(self.endpoint)
ws.send(
f"create-invoice --msatoshi {amount*1000} --description_hash {description_hash_hashed}"
f"create-invoice --msatoshi {amount*1000} --description_hash {description_hash_str}"
)
r = ws.recv()
else:
+11 -6
View File
@@ -82,21 +82,26 @@ class CoreLightningWallet(Wallet):
amount: int,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
) -> InvoiceResponse:
label = "lbl{}".format(random.random())
msat = amount * 1000
msat: int = int(amount * 1000)
try:
if description_hash and not self.supports_description_hash:
raise Unsupported("description_hash")
if description_hash and not unhashed_description:
raise Unsupported(
"'description_hash' unsupported by CLN, provide 'unhashed_description'"
)
if unhashed_description and not self.supports_description_hash:
raise Unsupported("unhashed_description")
r = self.ln.invoice(
msatoshi=msat,
label=label,
description=description_hash.decode("utf-8")
if description_hash
description=unhashed_description.decode("utf-8")
if unhashed_description
else memo,
exposeprivatechannels=True,
deschashonly=True
if description_hash
if unhashed_description
else False, # we can't pass None here
)
+4 -1
View File
@@ -69,11 +69,14 @@ class EclairWallet(Wallet):
amount: int,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
) -> InvoiceResponse:
data: Dict = {"amountMsat": amount * 1000}
if description_hash:
data["description_hash"] = hashlib.sha256(description_hash).hexdigest()
data["description_hash"] = description_hash.hex()
elif unhashed_description:
data["description_hash"] = hashlib.sha256(unhashed_description).hexdigest()
else:
data["description"] = memo or ""
+5 -1
View File
@@ -35,6 +35,7 @@ class FakeWallet(Wallet):
amount: int,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
) -> InvoiceResponse:
# we set a default secret since FakeWallet is used for internal=True invoices
# and the user might not have configured a secret yet
@@ -61,7 +62,10 @@ class FakeWallet(Wallet):
data["timestamp"] = datetime.now().timestamp()
if description_hash:
data["tags_set"] = ["h"]
data["description_hash"] = description_hash.decode("utf-8")
data["description_hash"] = description_hash
elif unhashed_description:
data["tags_set"] = ["d"]
data["description_hash"] = hashlib.sha256(unhashed_description).digest()
else:
data["tags_set"] = ["d"]
data["memo"] = memo
+4 -1
View File
@@ -57,10 +57,13 @@ class LNbitsWallet(Wallet):
amount: int,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
) -> InvoiceResponse:
data: Dict = {"out": False, "amount": amount}
if description_hash:
data["description_hash"] = hashlib.sha256(description_hash).hexdigest()
data["description_hash"] = description_hash.hex()
elif unhashed_description:
data["description_hash"] = hashlib.sha256(unhashed_description).hexdigest()
else:
data["memo"] = memo or ""
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,871 @@
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
"""Client and server classes corresponding to protobuf-defined services."""
import grpc
import lnbits.wallets.lnd_grpc_files.lightning_pb2 as lightning__pb2
import lnbits.wallets.lnd_grpc_files.router_pb2 as router__pb2
class RouterStub(object):
"""Router is a service that offers advanced interaction with the router
subsystem of the daemon.
"""
def __init__(self, channel):
"""Constructor.
Args:
channel: A grpc.Channel.
"""
self.SendPaymentV2 = channel.unary_stream(
"/routerrpc.Router/SendPaymentV2",
request_serializer=router__pb2.SendPaymentRequest.SerializeToString,
response_deserializer=lightning__pb2.Payment.FromString,
)
self.TrackPaymentV2 = channel.unary_stream(
"/routerrpc.Router/TrackPaymentV2",
request_serializer=router__pb2.TrackPaymentRequest.SerializeToString,
response_deserializer=lightning__pb2.Payment.FromString,
)
self.EstimateRouteFee = channel.unary_unary(
"/routerrpc.Router/EstimateRouteFee",
request_serializer=router__pb2.RouteFeeRequest.SerializeToString,
response_deserializer=router__pb2.RouteFeeResponse.FromString,
)
self.SendToRoute = channel.unary_unary(
"/routerrpc.Router/SendToRoute",
request_serializer=router__pb2.SendToRouteRequest.SerializeToString,
response_deserializer=router__pb2.SendToRouteResponse.FromString,
)
self.SendToRouteV2 = channel.unary_unary(
"/routerrpc.Router/SendToRouteV2",
request_serializer=router__pb2.SendToRouteRequest.SerializeToString,
response_deserializer=lightning__pb2.HTLCAttempt.FromString,
)
self.ResetMissionControl = channel.unary_unary(
"/routerrpc.Router/ResetMissionControl",
request_serializer=router__pb2.ResetMissionControlRequest.SerializeToString,
response_deserializer=router__pb2.ResetMissionControlResponse.FromString,
)
self.QueryMissionControl = channel.unary_unary(
"/routerrpc.Router/QueryMissionControl",
request_serializer=router__pb2.QueryMissionControlRequest.SerializeToString,
response_deserializer=router__pb2.QueryMissionControlResponse.FromString,
)
self.XImportMissionControl = channel.unary_unary(
"/routerrpc.Router/XImportMissionControl",
request_serializer=router__pb2.XImportMissionControlRequest.SerializeToString,
response_deserializer=router__pb2.XImportMissionControlResponse.FromString,
)
self.GetMissionControlConfig = channel.unary_unary(
"/routerrpc.Router/GetMissionControlConfig",
request_serializer=router__pb2.GetMissionControlConfigRequest.SerializeToString,
response_deserializer=router__pb2.GetMissionControlConfigResponse.FromString,
)
self.SetMissionControlConfig = channel.unary_unary(
"/routerrpc.Router/SetMissionControlConfig",
request_serializer=router__pb2.SetMissionControlConfigRequest.SerializeToString,
response_deserializer=router__pb2.SetMissionControlConfigResponse.FromString,
)
self.QueryProbability = channel.unary_unary(
"/routerrpc.Router/QueryProbability",
request_serializer=router__pb2.QueryProbabilityRequest.SerializeToString,
response_deserializer=router__pb2.QueryProbabilityResponse.FromString,
)
self.BuildRoute = channel.unary_unary(
"/routerrpc.Router/BuildRoute",
request_serializer=router__pb2.BuildRouteRequest.SerializeToString,
response_deserializer=router__pb2.BuildRouteResponse.FromString,
)
self.SubscribeHtlcEvents = channel.unary_stream(
"/routerrpc.Router/SubscribeHtlcEvents",
request_serializer=router__pb2.SubscribeHtlcEventsRequest.SerializeToString,
response_deserializer=router__pb2.HtlcEvent.FromString,
)
self.SendPayment = channel.unary_stream(
"/routerrpc.Router/SendPayment",
request_serializer=router__pb2.SendPaymentRequest.SerializeToString,
response_deserializer=router__pb2.PaymentStatus.FromString,
)
self.TrackPayment = channel.unary_stream(
"/routerrpc.Router/TrackPayment",
request_serializer=router__pb2.TrackPaymentRequest.SerializeToString,
response_deserializer=router__pb2.PaymentStatus.FromString,
)
self.HtlcInterceptor = channel.stream_stream(
"/routerrpc.Router/HtlcInterceptor",
request_serializer=router__pb2.ForwardHtlcInterceptResponse.SerializeToString,
response_deserializer=router__pb2.ForwardHtlcInterceptRequest.FromString,
)
self.UpdateChanStatus = channel.unary_unary(
"/routerrpc.Router/UpdateChanStatus",
request_serializer=router__pb2.UpdateChanStatusRequest.SerializeToString,
response_deserializer=router__pb2.UpdateChanStatusResponse.FromString,
)
class RouterServicer(object):
"""Router is a service that offers advanced interaction with the router
subsystem of the daemon.
"""
def SendPaymentV2(self, request, context):
"""
SendPaymentV2 attempts to route a payment described by the passed
PaymentRequest to the final destination. The call returns a stream of
payment updates.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def TrackPaymentV2(self, request, context):
"""
TrackPaymentV2 returns an update stream for the payment identified by the
payment hash.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def EstimateRouteFee(self, request, context):
"""
EstimateRouteFee allows callers to obtain a lower bound w.r.t how much it
may cost to send an HTLC to the target end destination.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def SendToRoute(self, request, context):
"""
Deprecated, use SendToRouteV2. SendToRoute attempts to make a payment via
the specified route. This method differs from SendPayment in that it
allows users to specify a full route manually. This can be used for
things like rebalancing, and atomic swaps. It differs from the newer
SendToRouteV2 in that it doesn't return the full HTLC information.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def SendToRouteV2(self, request, context):
"""
SendToRouteV2 attempts to make a payment via the specified route. This
method differs from SendPayment in that it allows users to specify a full
route manually. This can be used for things like rebalancing, and atomic
swaps.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def ResetMissionControl(self, request, context):
"""
ResetMissionControl clears all mission control state and starts with a clean
slate.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def QueryMissionControl(self, request, context):
"""
QueryMissionControl exposes the internal mission control state to callers.
It is a development feature.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def XImportMissionControl(self, request, context):
"""
XImportMissionControl is an experimental API that imports the state provided
to the internal mission control's state, using all results which are more
recent than our existing values. These values will only be imported
in-memory, and will not be persisted across restarts.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def GetMissionControlConfig(self, request, context):
"""
GetMissionControlConfig returns mission control's current config.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def SetMissionControlConfig(self, request, context):
"""
SetMissionControlConfig will set mission control's config, if the config
provided is valid.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def QueryProbability(self, request, context):
"""
QueryProbability returns the current success probability estimate for a
given node pair and amount.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def BuildRoute(self, request, context):
"""
BuildRoute builds a fully specified route based on a list of hop public
keys. It retrieves the relevant channel policies from the graph in order to
calculate the correct fees and time locks.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def SubscribeHtlcEvents(self, request, context):
"""
SubscribeHtlcEvents creates a uni-directional stream from the server to
the client which delivers a stream of htlc events.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def SendPayment(self, request, context):
"""
Deprecated, use SendPaymentV2. SendPayment attempts to route a payment
described by the passed PaymentRequest to the final destination. The call
returns a stream of payment status updates.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def TrackPayment(self, request, context):
"""
Deprecated, use TrackPaymentV2. TrackPayment returns an update stream for
the payment identified by the payment hash.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def HtlcInterceptor(self, request_iterator, context):
"""*
HtlcInterceptor dispatches a bi-directional streaming RPC in which
Forwarded HTLC requests are sent to the client and the client responds with
a boolean that tells LND if this htlc should be intercepted.
In case of interception, the htlc can be either settled, cancelled or
resumed later by using the ResolveHoldForward endpoint.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def UpdateChanStatus(self, request, context):
"""
UpdateChanStatus attempts to manually set the state of a channel
(enabled, disabled, or auto). A manual "disable" request will cause the
channel to stay disabled until a subsequent manual request of either
"enable" or "auto".
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def add_RouterServicer_to_server(servicer, server):
rpc_method_handlers = {
"SendPaymentV2": grpc.unary_stream_rpc_method_handler(
servicer.SendPaymentV2,
request_deserializer=router__pb2.SendPaymentRequest.FromString,
response_serializer=lightning__pb2.Payment.SerializeToString,
),
"TrackPaymentV2": grpc.unary_stream_rpc_method_handler(
servicer.TrackPaymentV2,
request_deserializer=router__pb2.TrackPaymentRequest.FromString,
response_serializer=lightning__pb2.Payment.SerializeToString,
),
"EstimateRouteFee": grpc.unary_unary_rpc_method_handler(
servicer.EstimateRouteFee,
request_deserializer=router__pb2.RouteFeeRequest.FromString,
response_serializer=router__pb2.RouteFeeResponse.SerializeToString,
),
"SendToRoute": grpc.unary_unary_rpc_method_handler(
servicer.SendToRoute,
request_deserializer=router__pb2.SendToRouteRequest.FromString,
response_serializer=router__pb2.SendToRouteResponse.SerializeToString,
),
"SendToRouteV2": grpc.unary_unary_rpc_method_handler(
servicer.SendToRouteV2,
request_deserializer=router__pb2.SendToRouteRequest.FromString,
response_serializer=lightning__pb2.HTLCAttempt.SerializeToString,
),
"ResetMissionControl": grpc.unary_unary_rpc_method_handler(
servicer.ResetMissionControl,
request_deserializer=router__pb2.ResetMissionControlRequest.FromString,
response_serializer=router__pb2.ResetMissionControlResponse.SerializeToString,
),
"QueryMissionControl": grpc.unary_unary_rpc_method_handler(
servicer.QueryMissionControl,
request_deserializer=router__pb2.QueryMissionControlRequest.FromString,
response_serializer=router__pb2.QueryMissionControlResponse.SerializeToString,
),
"XImportMissionControl": grpc.unary_unary_rpc_method_handler(
servicer.XImportMissionControl,
request_deserializer=router__pb2.XImportMissionControlRequest.FromString,
response_serializer=router__pb2.XImportMissionControlResponse.SerializeToString,
),
"GetMissionControlConfig": grpc.unary_unary_rpc_method_handler(
servicer.GetMissionControlConfig,
request_deserializer=router__pb2.GetMissionControlConfigRequest.FromString,
response_serializer=router__pb2.GetMissionControlConfigResponse.SerializeToString,
),
"SetMissionControlConfig": grpc.unary_unary_rpc_method_handler(
servicer.SetMissionControlConfig,
request_deserializer=router__pb2.SetMissionControlConfigRequest.FromString,
response_serializer=router__pb2.SetMissionControlConfigResponse.SerializeToString,
),
"QueryProbability": grpc.unary_unary_rpc_method_handler(
servicer.QueryProbability,
request_deserializer=router__pb2.QueryProbabilityRequest.FromString,
response_serializer=router__pb2.QueryProbabilityResponse.SerializeToString,
),
"BuildRoute": grpc.unary_unary_rpc_method_handler(
servicer.BuildRoute,
request_deserializer=router__pb2.BuildRouteRequest.FromString,
response_serializer=router__pb2.BuildRouteResponse.SerializeToString,
),
"SubscribeHtlcEvents": grpc.unary_stream_rpc_method_handler(
servicer.SubscribeHtlcEvents,
request_deserializer=router__pb2.SubscribeHtlcEventsRequest.FromString,
response_serializer=router__pb2.HtlcEvent.SerializeToString,
),
"SendPayment": grpc.unary_stream_rpc_method_handler(
servicer.SendPayment,
request_deserializer=router__pb2.SendPaymentRequest.FromString,
response_serializer=router__pb2.PaymentStatus.SerializeToString,
),
"TrackPayment": grpc.unary_stream_rpc_method_handler(
servicer.TrackPayment,
request_deserializer=router__pb2.TrackPaymentRequest.FromString,
response_serializer=router__pb2.PaymentStatus.SerializeToString,
),
"HtlcInterceptor": grpc.stream_stream_rpc_method_handler(
servicer.HtlcInterceptor,
request_deserializer=router__pb2.ForwardHtlcInterceptResponse.FromString,
response_serializer=router__pb2.ForwardHtlcInterceptRequest.SerializeToString,
),
"UpdateChanStatus": grpc.unary_unary_rpc_method_handler(
servicer.UpdateChanStatus,
request_deserializer=router__pb2.UpdateChanStatusRequest.FromString,
response_serializer=router__pb2.UpdateChanStatusResponse.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
"routerrpc.Router", rpc_method_handlers
)
server.add_generic_rpc_handlers((generic_handler,))
# This class is part of an EXPERIMENTAL API.
class Router(object):
"""Router is a service that offers advanced interaction with the router
subsystem of the daemon.
"""
@staticmethod
def SendPaymentV2(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_stream(
request,
target,
"/routerrpc.Router/SendPaymentV2",
router__pb2.SendPaymentRequest.SerializeToString,
lightning__pb2.Payment.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
@staticmethod
def TrackPaymentV2(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_stream(
request,
target,
"/routerrpc.Router/TrackPaymentV2",
router__pb2.TrackPaymentRequest.SerializeToString,
lightning__pb2.Payment.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
@staticmethod
def EstimateRouteFee(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_unary(
request,
target,
"/routerrpc.Router/EstimateRouteFee",
router__pb2.RouteFeeRequest.SerializeToString,
router__pb2.RouteFeeResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
@staticmethod
def SendToRoute(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_unary(
request,
target,
"/routerrpc.Router/SendToRoute",
router__pb2.SendToRouteRequest.SerializeToString,
router__pb2.SendToRouteResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
@staticmethod
def SendToRouteV2(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_unary(
request,
target,
"/routerrpc.Router/SendToRouteV2",
router__pb2.SendToRouteRequest.SerializeToString,
lightning__pb2.HTLCAttempt.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
@staticmethod
def ResetMissionControl(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_unary(
request,
target,
"/routerrpc.Router/ResetMissionControl",
router__pb2.ResetMissionControlRequest.SerializeToString,
router__pb2.ResetMissionControlResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
@staticmethod
def QueryMissionControl(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_unary(
request,
target,
"/routerrpc.Router/QueryMissionControl",
router__pb2.QueryMissionControlRequest.SerializeToString,
router__pb2.QueryMissionControlResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
@staticmethod
def XImportMissionControl(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_unary(
request,
target,
"/routerrpc.Router/XImportMissionControl",
router__pb2.XImportMissionControlRequest.SerializeToString,
router__pb2.XImportMissionControlResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
@staticmethod
def GetMissionControlConfig(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_unary(
request,
target,
"/routerrpc.Router/GetMissionControlConfig",
router__pb2.GetMissionControlConfigRequest.SerializeToString,
router__pb2.GetMissionControlConfigResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
@staticmethod
def SetMissionControlConfig(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_unary(
request,
target,
"/routerrpc.Router/SetMissionControlConfig",
router__pb2.SetMissionControlConfigRequest.SerializeToString,
router__pb2.SetMissionControlConfigResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
@staticmethod
def QueryProbability(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_unary(
request,
target,
"/routerrpc.Router/QueryProbability",
router__pb2.QueryProbabilityRequest.SerializeToString,
router__pb2.QueryProbabilityResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
@staticmethod
def BuildRoute(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_unary(
request,
target,
"/routerrpc.Router/BuildRoute",
router__pb2.BuildRouteRequest.SerializeToString,
router__pb2.BuildRouteResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
@staticmethod
def SubscribeHtlcEvents(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_stream(
request,
target,
"/routerrpc.Router/SubscribeHtlcEvents",
router__pb2.SubscribeHtlcEventsRequest.SerializeToString,
router__pb2.HtlcEvent.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
@staticmethod
def SendPayment(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_stream(
request,
target,
"/routerrpc.Router/SendPayment",
router__pb2.SendPaymentRequest.SerializeToString,
router__pb2.PaymentStatus.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
@staticmethod
def TrackPayment(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_stream(
request,
target,
"/routerrpc.Router/TrackPayment",
router__pb2.TrackPaymentRequest.SerializeToString,
router__pb2.PaymentStatus.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
@staticmethod
def HtlcInterceptor(
request_iterator,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.stream_stream(
request_iterator,
target,
"/routerrpc.Router/HtlcInterceptor",
router__pb2.ForwardHtlcInterceptResponse.SerializeToString,
router__pb2.ForwardHtlcInterceptRequest.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
@staticmethod
def UpdateChanStatus(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_unary(
request,
target,
"/routerrpc.Router/UpdateChanStatus",
router__pb2.UpdateChanStatusRequest.SerializeToString,
router__pb2.UpdateChanStatusResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
+93 -29
View File
@@ -2,10 +2,11 @@ imports_ok = True
try:
import grpc
from google import protobuf
from grpc import RpcError
except ImportError: # pragma: nocover
imports_ok = False
import asyncio
import base64
import binascii
import hashlib
@@ -19,6 +20,8 @@ from .macaroon import AESCipher, load_macaroon
if imports_ok:
import lnbits.wallets.lnd_grpc_files.lightning_pb2 as ln
import lnbits.wallets.lnd_grpc_files.lightning_pb2_grpc as lnrpc
import lnbits.wallets.lnd_grpc_files.router_pb2 as router
import lnbits.wallets.lnd_grpc_files.router_pb2_grpc as routerrpc
from .base import (
InvoiceResponse,
@@ -111,6 +114,7 @@ class LndWallet(Wallet):
f"{self.endpoint}:{self.port}", composite_creds
)
self.rpc = lnrpc.LightningStub(channel)
self.routerpc = routerrpc.RouterStub(channel)
def metadata_callback(self, _, callback):
callback([("macaroon", self.macaroon)], None)
@@ -118,6 +122,8 @@ class LndWallet(Wallet):
async def status(self) -> StatusResponse:
try:
resp = await self.rpc.ChannelBalance(ln.ChannelBalanceRequest())
except RpcError as exc:
return StatusResponse(str(exc._details), 0)
except Exception as exc:
return StatusResponse(str(exc), 0)
@@ -128,13 +134,15 @@ class LndWallet(Wallet):
amount: int,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
) -> InvoiceResponse:
params: Dict = {"value": amount, "expiry": 600, "private": True}
if description_hash:
params["description_hash"] = base64.b64encode(
hashlib.sha256(description_hash).digest()
) # as bytes directly
params["description_hash"] = description_hash
elif unhashed_description:
params["description_hash"] = hashlib.sha256(
unhashed_description
).digest() # as bytes directly
else:
params["memo"] = memo or ""
@@ -150,18 +158,39 @@ class LndWallet(Wallet):
return InvoiceResponse(True, checking_id, payment_request, None)
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
fee_limit_fixed = ln.FeeLimit(fixed=fee_limit_msat // 1000)
req = ln.SendRequest(payment_request=bolt11, fee_limit=fee_limit_fixed)
resp = await self.rpc.SendPaymentSync(req)
# fee_limit_fixed = ln.FeeLimit(fixed=fee_limit_msat // 1000)
req = router.SendPaymentRequest(
payment_request=bolt11,
fee_limit_msat=fee_limit_msat,
timeout_seconds=30,
no_inflight_updates=True,
)
try:
resp = await self.routerpc.SendPaymentV2(req).read()
except RpcError as exc:
return PaymentResponse(False, "", 0, None, exc._details)
except Exception as exc:
return PaymentResponse(False, "", 0, None, str(exc))
if resp.payment_error:
return PaymentResponse(False, "", 0, None, resp.payment_error)
# PaymentStatus from https://github.com/lightningnetwork/lnd/blob/master/channeldb/payments.go#L178
statuses = {
0: None, # NON_EXISTENT
1: None, # IN_FLIGHT
2: True, # SUCCEEDED
3: False, # FAILED
}
r_hash = hashlib.sha256(resp.payment_preimage).digest()
checking_id = stringify_checking_id(r_hash)
fee_msat = resp.payment_route.total_fees_msat
preimage = resp.payment_preimage.hex()
return PaymentResponse(True, checking_id, fee_msat, preimage, None)
if resp.status in [0, 1, 3]:
fee_msat = 0
preimage = ""
checking_id = ""
elif resp.status == 2: # SUCCEEDED
fee_msat = resp.htlcs[-1].route.total_fees_msat
preimage = resp.payment_preimage
checking_id = resp.payment_hash
return PaymentResponse(
statuses[resp.status], checking_id, fee_msat, preimage, None
)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
try:
@@ -180,20 +209,55 @@ class LndWallet(Wallet):
return PaymentStatus(None)
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
return PaymentStatus(True)
"""
This routine checks the payment status using routerpc.TrackPaymentV2.
"""
try:
r_hash = parse_checking_id(checking_id)
if len(r_hash) != 32:
raise binascii.Error
except binascii.Error:
# this may happen if we switch between backend wallets
# that use different checking_id formats
return PaymentStatus(None)
# for some reason our checking_ids are in base64 but the payment hashes
# returned here are in hex, lnd is weird
checking_id = checking_id.replace("_", "/")
checking_id = base64.b64decode(checking_id).hex()
resp = self.routerpc.TrackPaymentV2(
router.TrackPaymentRequest(payment_hash=r_hash)
)
# HTLCAttempt.HTLCStatus:
# https://github.com/lightningnetwork/lnd/blob/master/lnrpc/lightning.proto#L3641
statuses = {
0: None, # IN_FLIGHT
1: True, # "SUCCEEDED"
2: False, # "FAILED"
}
try:
async for payment in resp:
return PaymentStatus(statuses[payment.htlcs[-1].status])
except: # most likely the payment wasn't found
return PaymentStatus(None)
return PaymentStatus(None)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
request = ln.InvoiceSubscription()
try:
async for i in self.rpc.SubscribeInvoices(request):
if not i.settled:
continue
while True:
request = ln.InvoiceSubscription()
try:
async for i in self.rpc.SubscribeInvoices(request):
if not i.settled:
continue
checking_id = stringify_checking_id(i.r_hash)
yield checking_id
except error:
logger.error(error)
logger.error(
"lost connection to lnd InvoiceSubscription, please restart lnbits."
)
checking_id = stringify_checking_id(i.r_hash)
yield checking_id
except Exception as exc:
logger.error(
f"lost connection to lnd invoices stream: '{exc}', retrying in 5 seconds"
)
await asyncio.sleep(5)
+37 -25
View File
@@ -73,11 +73,17 @@ class LndRestWallet(Wallet):
amount: int,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
**kwargs,
) -> InvoiceResponse:
data: Dict = {"value": amount, "private": True}
if description_hash:
data["description_hash"] = base64.b64encode(description_hash).decode(
"ascii"
)
elif unhashed_description:
data["description_hash"] = base64.b64encode(
hashlib.sha256(description_hash).digest()
hashlib.sha256(unhashed_description).digest()
).decode("ascii")
else:
data["memo"] = memo or ""
@@ -142,15 +148,10 @@ class LndRestWallet(Wallet):
return PaymentStatus(True)
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
async with httpx.AsyncClient(verify=self.cert) as client:
r = await client.get(
url=f"{self.endpoint}/v1/payments",
headers=self.auth,
params={"max_payments": "20", "reversed": True},
)
if r.is_error:
return PaymentStatus(None)
"""
This routine checks the payment status using routerpc.TrackPaymentV2.
"""
url = f"{self.endpoint}/v2/router/track/{checking_id}"
# check payment.status:
# https://api.lightning.community/rest/index.html?python#peersynctype
@@ -161,14 +162,27 @@ class LndRestWallet(Wallet):
"FAILED": False,
}
# for some reason our checking_ids are in base64 but the payment hashes
# returned here are in hex, lnd is weird
checking_id = checking_id.replace("_", "/")
checking_id = base64.b64decode(checking_id).hex()
for p in r.json()["payments"]:
if p["payment_hash"] == checking_id:
return PaymentStatus(statuses[p["status"]])
async with httpx.AsyncClient(
timeout=None, headers=self.auth, verify=self.cert
) as client:
async with client.stream("GET", url) as r:
async for l in r.aiter_lines():
try:
line = json.loads(l)
if line.get("error"):
logger.error(
line["error"]["message"]
if "message" in line["error"]
else line["error"]
)
return PaymentStatus(None)
payment = line.get("result")
if payment is not None and payment.get("status"):
return PaymentStatus(statuses[payment["status"]])
else:
return PaymentStatus(None)
except:
continue
return PaymentStatus(None)
@@ -191,10 +205,8 @@ class LndRestWallet(Wallet):
payment_hash = base64.b64decode(inv["r_hash"]).hex()
yield payment_hash
except (OSError, httpx.ConnectError, httpx.ReadError):
pass
logger.error(
"lost connection to lnd invoices stream, retrying in 5 seconds"
)
await asyncio.sleep(5)
except Exception as exc:
logger.error(
f"lost connection to lnd invoices stream: '{exc}', retrying in 5 seconds"
)
await asyncio.sleep(5)
+5 -1
View File
@@ -52,10 +52,14 @@ class LNPayWallet(Wallet):
amount: int,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
**kwargs,
) -> InvoiceResponse:
data: Dict = {"num_satoshis": f"{amount}"}
if description_hash:
data["description_hash"] = hashlib.sha256(description_hash).hexdigest()
data["description_hash"] = description_hash.hex()
elif unhashed_description:
data["description_hash"] = hashlib.sha256(unhashed_description).hexdigest()
else:
data["memo"] = memo or ""
+5 -1
View File
@@ -52,10 +52,14 @@ class LntxbotWallet(Wallet):
amount: int,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
**kwargs,
) -> InvoiceResponse:
data: Dict = {"amt": str(amount)}
if description_hash:
data["description_hash"] = hashlib.sha256(description_hash).hexdigest()
data["description_hash"] = description_hash.hex()
elif unhashed_description:
data["description_hash"] = hashlib.sha256(unhashed_description).hexdigest()
else:
data["memo"] = memo or ""
+3 -1
View File
@@ -54,8 +54,10 @@ class OpenNodeWallet(Wallet):
amount: int,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
**kwargs,
) -> InvoiceResponse:
if description_hash:
if description_hash or unhashed_description:
raise Unsupported("description_hash")
async with httpx.AsyncClient() as client:
+9 -1
View File
@@ -93,6 +93,8 @@ class SparkWallet(Wallet):
amount: int,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
**kwargs,
) -> InvoiceResponse:
label = "lbs{}".format(random.random())
checking_id = label
@@ -102,7 +104,13 @@ class SparkWallet(Wallet):
r = await self.invoicewithdescriptionhash(
msatoshi=amount * 1000,
label=label,
description_hash=hashlib.sha256(description_hash).hexdigest(),
description_hash=description_hash.hex(),
)
elif unhashed_description:
r = await self.invoicewithdescriptionhash(
msatoshi=amount * 1000,
label=label,
description_hash=hashlib.sha256(unhashed_description).hexdigest(),
)
else:
r = await self.invoice(
+3 -2
View File
@@ -18,6 +18,7 @@ class VoidWallet(Wallet):
amount: int,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
**kwargs,
) -> InvoiceResponse:
raise Unsupported("")
@@ -31,10 +32,10 @@ class VoidWallet(Wallet):
raise Unsupported("")
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
raise Unsupported("")
return PaymentStatus(None)
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
raise Unsupported("")
return PaymentStatus(None)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
yield ""