feat: initial support for bolt12

rebase of #3092 special thanks to @21M4TW
This commit is contained in:
21M4TW
2025-04-06 16:44:14 -04:00
committed by dni ⚡
parent 1016fdd9d0
commit f114912036
11 changed files with 1001 additions and 2 deletions

225
lnbits/core/crud/offers.py Normal file
View File

@@ -0,0 +1,225 @@
from time import time
from typing import Optional
from lnbits.core.db import db
from lnbits.core.models.offers import (
CreateOffer,
Offer,
OfferFilters,
OffersStatusCount,
OfferState,
)
from lnbits.db import Connection, Filters, Page
async def get_offer(offer_id: str, conn: Optional[Connection] = None) -> Offer:
return await (conn or db).fetchone(
"SELECT * FROM apioffers WHERE offer_id = :offer_id",
{"offer_id": offer_id},
Offer,
)
async def get_standalone_offer(
offer_id: str,
conn: Optional[Connection] = None,
wallet_id: Optional[str] = None,
) -> Optional[Offer]:
clause: str = "offer_id = :offer_id"
values = {
"wallet_id": wallet_id,
"offer_id": offer_id,
}
if wallet_id:
clause = f"({clause}) AND wallet_id = :wallet_id"
row = await (conn or db).fetchone(
"""
SELECT * FROM apioffers
WHERE {clause}
""",
values,
Offer,
)
return row
async def get_offers_paginated(
*,
wallet_id: Optional[str] = None,
active: Optional[bool] = None,
single_use: Optional[bool] = None,
since: Optional[int] = None,
filters: Optional[Filters[OfferFilters]] = None,
conn: Optional[Connection] = None,
) -> Page[Offer]:
"""
Filters offers to be returned by:
- active | single_use.
"""
values: dict = {
"wallet_id": wallet_id,
"created_at": since,
}
clause: list[str] = []
if since is not None:
clause.append(f"time > {db.timestamp_placeholder('time')}")
if wallet_id:
clause.append("wallet_id = :wallet_id")
if active is not None:
clause.append("active = :active")
if single_use is not None:
clause.append("single_use = :single_use")
return await (conn or db).fetch_page(
"SELECT * FROM apioffers",
clause,
values,
filters=filters,
model=Offer,
)
async def get_offers(
*,
wallet_id: Optional[str] = None,
active: Optional[bool] = None,
single_use: Optional[bool] = None,
since: Optional[int] = None,
filters: Optional[Filters[OfferFilters]] = None,
conn: Optional[Connection] = None,
limit: Optional[int] = None,
offset: Optional[int] = None,
) -> list[Offer]:
"""
Filters offers to be returned by active | single_use.
"""
filters = filters or Filters()
filters.sortby = filters.sortby or "time"
filters.direction = filters.direction or "desc"
filters.limit = limit or filters.limit
filters.offset = offset or filters.offset
page = await get_offers_paginated(
wallet_id=wallet_id,
active=active,
single_use=single_use,
since=since,
filters=filters,
conn=conn,
)
return page.data
async def get_offers_status_count() -> OffersStatusCount:
empty_page: Filters = Filters(limit=0)
active_offers = await get_offers_paginated(active=True, filters=empty_page)
single_use_offers = await get_offers_paginated(single_use=True, filters=empty_page)
return OffersStatusCount(
active=active_offers.total,
single_use=single_use_offers.total,
)
async def delete_expired_offers(
conn: Optional[Connection] = None,
) -> None:
# We delete all offers whose expiry date is in the past
await (conn or db).execute(
"""
DELETE FROM apioffers
WHERE expiry < {db.timestamp_placeholder("now")}
""",
{"now": int(time())},
)
async def create_offer(
offer_id: str,
data: CreateOffer,
active: OfferState,
single_use: OfferState,
conn: Optional[Connection] = None,
) -> Offer:
# we don't allow the creation of the same offer twice
# note: this can be removed if the db uniqueness constraints are set appropriately
previous_offer = await get_standalone_offer(offer_id, conn=conn)
assert previous_offer is None, "Offer already exists"
extra = data.extra or {}
offer = Offer(
offer_id=offer_id,
wallet_id=data.wallet_id,
amount=data.amount_msat,
active=active,
single_use=single_use,
bolt12=data.bolt12,
memo=data.memo,
expiry=data.expiry,
webhook=data.webhook,
tag=extra.get("tag", None),
extra=extra,
)
print(offer)
await (conn or db).insert("apioffers", offer)
return offer
async def update_offer(
offer: Offer,
conn: Optional[Connection] = None,
) -> None:
await (conn or db).update("apioffers", offer, "WHERE offer_id = :offer_id")
async def delete_wallet_offer(
offer_id: str, wallet_id: str, conn: Optional[Connection] = None
) -> None:
await (conn or db).execute(
"DELETE FROM apioffers WHERE offer_id = :offer_id AND wallet = :wallet",
{"offer_id": offer_id, "wallet": wallet_id},
)
async def enable_offer(offer_id: str) -> None:
await db.execute(
"""
UPDATE apioffers
SET active = :active, updated_at = {db.timestamp_placeholder("now")}
WHERE offer_id = :offer_id
""",
{"now": time(), "offer_id": offer_id, "active": OfferState.TRUE},
)
async def disable_offer(offer_id: str) -> None:
await db.execute(
"""
UPDATE apioffers
SET active = :active, updated_at = {db.timestamp_placeholder("now")}
WHERE offer_id = :offer_id
""",
{"now": time(), "offer_id": offer_id, "active": OfferState.FALSE},
)
async def mark_webhook_offer_sent(offer_id: str, status: int) -> None:
await db.execute(
"""
UPDATE apioffers SET webhook_status = :status
WHERE offer_id = :offer_id
""",
{"status": status, "offer_id": offer_id},
)

View File

@@ -275,7 +275,9 @@ async def create_payment(
payment_hash=data.payment_hash,
bolt11=data.bolt11,
amount=data.amount_msat,
offer_id=data.offer_id,
memo=data.memo,
payer_note=data.payer_note,
preimage=data.preimage,
expiry=data.expiry,
webhook=data.webhook,

View File

@@ -735,3 +735,31 @@ async def m032_add_external_id_to_accounts(db: Connection):
async def m033_update_payment_table(db: Connection):
await db.execute("ALTER TABLE apipayments ADD COLUMN fiat_provider TEXT")
async def m034_create_offer_table(db: Connection):
await db.execute("ALTER TABLE apipayments ADD COLUMN payer_note TEXT")
await db.execute("ALTER TABLE apipayments ADD COLUMN offer_id TEXT")
await db.execute(
"""
CREATE TABLE IF NOT EXISTS apioffers (
offer_id TEXT NOT NULL,
amount INT NOT NULL,
wallet_id TEXT NOT NULL,
memo TEXT,
bolt12 TEXT,
extra TEXT,
webhook TEXT,
webhook_status TEXT,
expiry TIMESTAMP,
active INT NOT NULL DEFAULT 0 CHECK(ABS(active)<=1),
single_use INT NOT NULL DEFAULT 0 CHECK(ABS(single_use)<=1),
tag TEXT,
extension TEXT,
created_at TIMESTAMP,
updated_at TIMESTAMP,
UNIQUE (wallet_id, offer_id)
)
"""
)
await db.execute("CREATE INDEX by_offer ON apioffers (offer_id)")

View File

@@ -0,0 +1,100 @@
from __future__ import annotations
from datetime import datetime, timezone
from enum import Enum
from pydantic import BaseModel, Field
from lnbits.db import FilterModel
from lnbits.wallets import get_funding_source
from lnbits.wallets.base import (
OfferStatus,
)
class OfferState(int, Enum):
TRUE = 1
FALSE = 0
def __int__(self) -> int:
return self.value
class CreateOffer(BaseModel):
wallet_id: str
bolt12: str
amount_msat: int
memo: str
extra: dict | None = {}
expiry: datetime | None = None
webhook: str | None = None
class Offer(BaseModel):
offer_id: str
wallet_id: str
amount: int
active: int
single_use: int
bolt12: str
memo: str | None = None
expiry: datetime | None = None
webhook: str | None = None
webhook_status: str | None = None
tag: str | None = None
extension: str | None = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
extra: dict = {}
@property
def is_active(self) -> bool:
return not self.active == OfferState.FALSE.value
@property
def is_inactive(self) -> bool:
return self.active == OfferState.FALSE.value
@property
def is_single_use(self) -> bool:
return not self.single_use == OfferState.FALSE.value
@property
def is_multiple_use(self) -> bool:
return self.single_use == OfferState.FALSE.value
@property
def msat(self) -> int:
return self.amount
@property
def sat(self) -> int:
return self.amount // 1000
@property
def is_expired(self) -> bool:
return self.expiry < datetime.now(timezone.utc) if self.expiry else False
async def check_status(self) -> OfferStatus:
funding_source = get_funding_source()
status = await funding_source.get_offer_status(self.offer_id)
return status
class OfferFilters(FilterModel):
__search_fields__ = ["memo", "amount", "wallet_id", "tag", "active", "single_use"]
__sort_fields__ = ["created_at", "amount", "fee", "memo", "tag"]
active: int | None
single_use: int | None
tag: str | None
offer_id: str | None
amount: int
memo: str | None
wallet_id: str | None
class OffersStatusCount(BaseModel):
active: int = 0
single_use: int = 0

View File

@@ -52,10 +52,14 @@ class CreatePayment(BaseModel):
payment_hash: str
bolt11: str
amount_msat: int
offer_id: str | None = None
memo: str
payer_note: str | None = None
extra: dict | None = {}
preimage: str | None = None
expiry: datetime | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
webhook: str | None = None
fee: int = 0
@@ -70,7 +74,9 @@ class Payment(BaseModel):
# payment_request: str | None
fiat_provider: str | None = None
status: str = PaymentState.PENDING
offer_id: str | None = None
memo: str | None = None
payer_note: str | None = None
expiry: datetime | None = None
webhook: str | None = None
webhook_status: str | None = None

View File

@@ -1,5 +1,6 @@
import asyncio
import json
import secrets
import time
from datetime import datetime, timedelta, timezone
from typing import Optional
@@ -16,7 +17,7 @@ from lnbits.core.models import PaymentDailyStats, PaymentFilters
from lnbits.core.models.payments import CreateInvoice
from lnbits.db import Connection, Filters
from lnbits.decorators import check_user_extension_access
from lnbits.exceptions import InvoiceError, PaymentError, UnsupportedError
from lnbits.exceptions import InvoiceError, OfferError, PaymentError, UnsupportedError
from lnbits.fiat import get_fiat_provider
from lnbits.helpers import check_callback_url
from lnbits.settings import settings
@@ -42,8 +43,19 @@ from ..crud import (
is_internal_status_success,
update_payment,
)
from ..crud import (
create_offer as crud_create_offer,
)
from ..crud import (
disable_offer as crud_disable_offer,
)
from ..crud import (
enable_offer as crud_enable_offer,
)
from ..models import (
CreateOffer,
CreatePayment,
Offer,
Payment,
PaymentState,
Wallet,
@@ -54,6 +66,155 @@ payment_lock = asyncio.Lock()
wallets_payments_lock: dict[str, asyncio.Lock] = {}
async def create_offer(
*,
wallet_id: str,
amount_sat: int,
memo: str,
absolute_expiry: Optional[int] = None,
single_use: Optional[bool] = None,
extra: Optional[dict] = None,
webhook: Optional[str] = None,
conn: Optional[Connection] = None,
) -> Offer:
if amount_sat < 0:
raise OfferError("Offers with negative amounts are not valid.", status="failed")
user_wallet = await get_wallet(wallet_id, conn=conn)
if not user_wallet:
raise OfferError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
offer_memo = memo[:640]
funding_source = get_funding_source()
# How should this be handled with offers?
if amount_sat > settings.lnbits_max_incoming_payment_amount_sats:
raise OfferError(
f"Offer amount {amount_sat} sats is too high. Max allowed: "
f"{settings.lnbits_max_incoming_payment_amount_sats} sats.",
status="failed",
)
if settings.is_wallet_max_balance_exceeded(
user_wallet.balance_msat / 1000 + amount_sat
):
raise OfferError(
f"Wallet balance cannot exceed "
f"{settings.lnbits_wallet_limit_max_balance} sats.",
status="failed",
)
while True:
offer_resp = await funding_source.create_offer(
amount=amount_sat,
issuer=secrets.token_hex(8),
memo=offer_memo,
absolute_expiry=absolute_expiry,
single_use=single_use,
)
if not offer_resp.ok or not offer_resp.invoice_offer or not offer_resp.offer_id:
raise OfferError(
offer_resp.error_message or "unexpected backend error.",
status="pending",
)
if offer_resp.created:
break
create_offer_model = CreateOffer(
wallet_id=wallet_id,
bolt12=offer_resp.invoice_offer,
amount_msat=amount_sat * 1000,
memo=memo,
extra=extra,
expiry=absolute_expiry,
webhook=webhook,
)
offer = await crud_create_offer(
offer_id=offer_resp.offer_id,
data=create_offer_model,
active=offer_resp.active,
single_use=offer_resp.single_use,
conn=conn,
)
return offer
async def enable_offer(
*,
wallet_id: str,
offer_id: str,
conn: Optional[Connection] = None,
) -> bool:
user_wallet = await get_wallet(wallet_id, conn=conn)
if not user_wallet:
raise OfferError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
funding_source = get_funding_source()
offer_resp = await funding_source.enable_offer(offer_id=offer_id)
if not offer_resp.ok:
raise OfferError(
offer_resp.error_message or "unexpected backend error.", status="pending"
)
if offer_resp.created:
if (
not offer_resp.invoice_offer
or not offer_resp.offer_id == offer_id
or not offer_resp.active
):
raise OfferError(
offer_resp.error_message or "unexpected backend state.", status="error"
)
await crud_enable_offer(offer_resp.offer_id)
return True
async def disable_offer(
*,
wallet_id: str,
offer_id: str,
conn: Optional[Connection] = None,
) -> bool:
user_wallet = await get_wallet(wallet_id, conn=conn)
if not user_wallet:
raise OfferError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
funding_source = get_funding_source()
offer_resp = await funding_source.disable_offer(offer_id=offer_id)
if not offer_resp.ok:
raise OfferError(
offer_resp.error_message or "unexpected backend error.", status="pending"
)
if offer_resp.created:
if (
not offer_resp.invoice_offer
or not offer_resp.offer_id == offer_id
or offer_resp.active
):
raise OfferError(
offer_resp.error_message or "unexpected backend state.", status="error"
)
await crud_disable_offer(offer_resp.offer_id)
return False
async def pay_invoice(
*,
wallet_id: str,

View File

@@ -14,6 +14,12 @@ from lnbits.settings import settings
from .helpers import path_segments, template_renderer
class OfferError(Exception):
def __init__(self, message: str, status: str = "pending"):
self.message = message
self.status = status
class PaymentError(Exception):
def __init__(self, message: str, status: str = "pending"):
self.message = message

View File

@@ -14,7 +14,9 @@ from lnbits.core.crud import (
update_payment,
)
from lnbits.core.models import Payment, PaymentState
from lnbits.core.services.fiat_providers import handle_fiat_payment_confirmation
from lnbits.core.services.fiat_providers import (
handle_fiat_payment_confirmation,
)
from lnbits.settings import settings
from lnbits.wallets import get_funding_source
@@ -191,3 +193,66 @@ async def invoice_callback_dispatcher(checking_id: str, is_internal: bool = Fals
for name, send_chan in invoice_listeners.items():
logger.trace(f"invoice listeners: sending to `{name}`")
await send_chan.put(payment)
# if payment:
# if payment.is_in:
# status = await payment.check_status()
# payment.fee = status.fee_msat or 0
# payment.preimage = status.preimage
# payment.status = PaymentState.SUCCESS
# await update_payment(payment)
# internal = "internal" if is_internal else ""
# logger.success(f"{internal} invoice {checking_id} settled")
# for name, send_chan in invoice_listeners.items():
# logger.trace(f"invoice listeners: sending to `{name}`")
# await send_chan.put(payment)
# else:
# funding_source = get_funding_source()
# data = await funding_source.get_bolt12_invoice_main_data(checking_id)
# logger.debug(f"Returned main invoice data is {data}")
# if data and data.success:
# logger.success(f"Bolt12
# data successfully recovered for invoice {checking_id}")
# offer = await get_standalone_offer(data.offer_id)
# if offer:
# logger.success(f"Offer {data.offer_id} was found in db")
# description = data.description or f"Offer {data.offer_id} payment"
# invoice = Bolt11(
# currency="bc",
# amount_msat=data.amount_msat,
# date=data.created_at,
# tags=Tags.from_dict(
# {
# "payment_hash": data.payment_hash,
# "payment_secret": data.payment_preimage,
# "description": description,
# "expire_time": data.expires_at-data.created_at,
# } ),
# )
# privkey = fake_privkey(settings.fake_wallet_secret)
# bolt11 = bolt11_encode(invoice, privkey)
# create_payment_model = CreatePayment(
# wallet_id=offer.wallet_id,
# bolt11=bolt11,
# payment_hash=data.payment_hash,
# preimage=data.payment_preimage,
# amount_msat=data.amount_msat,
# offer_id=data.offer_id,
# expiry=data.expires_at,
# created_at=data.created_at,
# updated_at=data.paid_at,
# memo=description,
# )
# payment = await create_payment(
# checking_id=checking_id,
# data=create_payment_model,
# status = PaymentState.SUCCESS
# )
# logger.success(f"invoice {checking_id} settled")
# for name, send_chan in invoice_listeners.items():
# logger.trace(f"invoice listeners: sending to `{name}`")
# await send_chan.put(payment)

View File

@@ -26,6 +26,91 @@ class StatusResponse(NamedTuple):
balance_msat: int
class OfferResponse(NamedTuple):
ok: bool
offer_id: str | None = None
active: bool | None = None
single_use: bool | None = None
invoice_offer: str | None = None
used: bool | None = None
created: bool | None = None
label: str | None = None
error_message: str | None = None
@property
def success(self) -> bool:
return self.ok is True
@property
def failed(self) -> bool:
return self.ok is not True
class OfferStatus(NamedTuple):
active: bool | None = None
used: bool | None = None
@property
def active(self) -> bool:
return self.active is True
@property
def used(self) -> bool:
return self.used is True
@property
def error(self) -> bool:
return self.active is None
class OfferErrorStatus(OfferStatus):
active = None
used = None
class FetchInvoiceResponse(NamedTuple):
ok: bool
payment_request: str | None = None
error_message: str | None = None
@property
def success(self) -> bool:
return self.ok is True
@property
def pending(self) -> bool:
return self.ok is None
@property
def failed(self) -> bool:
return self.ok is False
class InvoiceMainData(NamedTuple):
paid: bool | None = None
payment_hash: str | None = None
description: str | None = None
payer_note: str | None = None
amount_msat: int | None = None
offer_id: str | None = None
expires_at: int | None = None
created_at: int | None = None
paid_at: int | None = None
payment_preimage: str | None = None
@property
def success(self) -> bool:
return self.paid is True
@property
def pending(self) -> bool:
return self.paid is None
@property
def failed(self) -> bool:
return self.paid is False
class InvoiceResponse(NamedTuple):
ok: bool
checking_id: str | None = None # payment_hash, rpc_id
@@ -176,6 +261,11 @@ class Wallet(ABC):
message="Hold invoices are not supported by this wallet.", status="failed"
)
async def get_bolt12_invoice_main_data(
self, checking_id: str
) -> InvoiceMainData | None:
return None
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
while settings.lnbits_running:
for invoice in self.pending_invoices:

View File

@@ -1,4 +1,5 @@
import asyncio
import random
from collections.abc import AsyncGenerator
from secrets import token_urlsafe
from typing import Any, Optional
@@ -15,7 +16,12 @@ from lnbits.utils.crypto import random_secret_and_hash
from .base import (
Feature,
FetchInvoiceResponse,
InvoiceMainData,
InvoiceResponse,
OfferErrorStatus,
OfferResponse,
OfferStatus,
PaymentFailedStatus,
PaymentPendingStatus,
PaymentResponse,
@@ -89,6 +95,218 @@ class CoreLightningWallet(Wallet):
logger.warning(f"Failed to connect, got: '{exc}'")
return StatusResponse(f"Unable to connect, got: '{exc}'", 0)
async def create_offer(
self,
amount: int,
memo: Optional[str] = None,
issuer: Optional[str] = None,
absolute_expiry: Optional[int] = None,
single_use: Optional[bool] = None,
**kwargs,
) -> OfferResponse:
label = kwargs.get("label", f"lbl{random.random()}") # noqa: S311
try:
payload = {
"amount": int(amount * 1000) if amount > 0 else "any",
"description": memo,
"issuer": issuer,
"label": label,
"absolute_expiry": absolute_expiry,
"single_use": single_use,
}
r: dict = self.ln.call("offer", payload)
if r.get("code") and r.get("code") < 0: # type: ignore
raise Exception(r.get("message"))
return OfferResponse(
True,
r["offer_id"],
r["active"],
r["single_use"],
r["bolt12"],
r["used"],
r["created"],
r["label"],
None,
)
except RpcError as exc:
logger.warning(exc)
error_message = f"RPC '{exc.method}' failed with '{exc.error}'."
return OfferResponse(
False, None, None, None, None, None, None, None, error_message
)
except KeyError as exc:
logger.warning(exc)
return OfferResponse(
False,
None,
None,
None,
None,
None,
None,
None,
"Server error: 'missing required fields'",
)
except Exception as e:
logger.warning(e)
return OfferResponse(
False, None, None, None, None, None, None, None, str(e)
)
async def enable_offer(
self,
offer_id: str,
) -> OfferResponse:
try:
payload = {
"offer_id": offer_id,
}
r: dict = self.ln.call("enableoffer", payload)
if r.get("code"):
if r.get("code") == 1006:
return OfferResponse(
True, None, None, None, None, None, False, None, None
)
else: # type: ignore
raise Exception(r.get("message"))
return OfferResponse(
True,
r["offer_id"],
r["active"],
r["single_use"],
r["bolt12"],
r["used"],
True,
r["label"],
None,
)
except RpcError as exc:
logger.warning(exc)
error_message = f"RPC '{exc.method}' failed with '{exc.error}'."
return OfferResponse(
False, None, None, None, None, None, None, None, error_message
)
except KeyError as exc:
logger.warning(exc)
return OfferResponse(
False, None, None, None, "Server error: 'missing required fields'"
)
except Exception as e:
logger.warning(e)
return OfferResponse(
False, None, None, None, None, None, None, None, str(e)
)
async def disable_offer(
self,
offer_id: str,
) -> OfferResponse:
try:
payload = {
"offer_id": offer_id,
}
r: dict = self.ln.call("disableoffer", payload)
if r.get("code"):
if r.get("code") == 1001:
return OfferResponse(
True, None, None, None, None, None, False, None, None
)
else: # type: ignore
raise Exception(r.get("message"))
return OfferResponse(
True,
r["offer_id"],
r["active"],
r["single_use"],
r["bolt12"],
r["used"],
True,
r["label"],
None,
)
except RpcError as exc:
logger.warning(exc)
error_message = f"RPC '{exc.method}' failed with '{exc.error}'."
return OfferResponse(
False, None, None, None, None, None, None, None, error_message
)
except KeyError as exc:
logger.warning(exc)
return OfferResponse(
False, None, None, None, "Server error: 'missing required fields'"
)
except Exception as e:
logger.warning(e)
return OfferResponse(
False, None, None, None, None, None, None, None, str(e)
)
async def get_offer_status(
self, offer_id: str, active_only: bool = False
) -> OfferStatus:
try:
payload = {
"offer_id": offer_id,
"active_only": active_only,
}
r: dict = self.ln.call("listoffers", payload)
if not r["offers"]:
return OfferErrorStatus()
offer_resp = r["offers"][-1]
if offer_resp["offer_id"] == offer_id:
return OfferStatus(offer_resp["active"], offer_resp["used"])
else:
logger.warning(f"supplied an invalid offer_id: {offer_id}")
return OfferErrorStatus()
except RpcError as exc:
logger.warning(exc)
return OfferErrorStatus()
except Exception as exc:
logger.warning(exc)
return OfferErrorStatus()
async def fetch_invoice(
self,
offer_id: str,
amount: Optional[int] = None,
) -> FetchInvoiceResponse:
try:
payload = {
"offer": offer_id,
"amount_msat": int(amount * 1000) if amount else None,
}
r: dict = self.ln.call("fetchinvoice", payload)
if r.get("code") and r.get("code") < 0: # type: ignore
raise Exception(r.get("message"))
return FetchInvoiceResponse(True, r["invoice"], None)
except RpcError as exc:
logger.warning(exc)
error_message = f"RPC '{exc.method}' failed with '{exc.error}'."
return FetchInvoiceResponse(False, None, error_message)
except KeyError as exc:
logger.warning(exc)
return FetchInvoiceResponse(
False, None, "Server error: 'missing required fields'"
)
except Exception as e:
logger.warning(e)
return FetchInvoiceResponse(False, None, str(e))
async def create_invoice(
self,
amount: int,
@@ -205,6 +423,70 @@ class CoreLightningWallet(Wallet):
logger.warning(exc)
return PaymentResponse(error_message=f"Payment failed: '{exc}'.")
async def get_bolt12_invoice_main_data(
self, checking_id: str
) -> Optional[InvoiceMainData]:
try:
r: dict = self.ln.listinvoices(payment_hash=checking_id)
if not r["invoices"]:
raise Exception(f"Invoice with checking_id {checking_id}")
invoice_resp = r["invoices"][-1]
logger.debug(f"Returned invoice response is {invoice_resp}")
if invoice_resp["payment_hash"] != checking_id:
raise Exception(f"Supplied an invalid checking_id: {checking_id}")
if not invoice_resp["bolt12"]:
raise Exception("Invoice does not contain bolt12 data")
payload = {"string": invoice_resp["bolt12"]}
r2: dict = self.ln.call("decode", payload)
logger.debug(f"Returned decoded bolt12 invoice is {r2}")
if not r2["type"] == "bolt12 invoice":
raise Exception("Provided string is not a bolt12 invoice")
if r2["valid"] is False:
raise Exception("Provided bolt12 invoice is invalid")
if invoice_resp["status"] == "paid":
return InvoiceMainData(
paid=True,
payment_hash=invoice_resp["payment_hash"],
description=invoice_resp.get("description"),
payer_note=r.get("invreq_payer_note"),
amount_msat=invoice_resp["amount_msat"],
offer_id=invoice_resp["local_offer_id"],
expires_at=invoice_resp["expires_at"],
created_at=r["invoice_created_at"],
paid_at=invoice_resp["paid_at"],
payment_preimage=invoice_resp["payment_preimage"],
)
else:
return InvoiceMainData(
paid=None if invoice_resp["status"] == "unpaid" else False,
payment_hash=invoice_resp["payment_hash"],
description=invoice_resp.get("description"),
payer_note=r.get("invreq_payer_note"),
amount_msat=invoice_resp["amount_msat"],
offer_id=invoice_resp["local_offer_id"],
expires_at=invoice_resp["expires_at"],
created_at=r["invoice_created_at"],
paid_at=None,
payment_preimage=None,
)
except RpcError as exc:
logger.warning(exc)
return None
except Exception as exc:
logger.warning(exc)
return None
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
try:
r: dict = self.ln.listinvoices(payment_hash=checking_id) # type: ignore

View File

@@ -0,0 +1,34 @@
import pytest
from lnbits.core.services import create_offer, disable_offer
from lnbits.wallets import get_funding_source, set_funding_source
from lnbits.wallets.base import OfferStatus
description = "test create offer"
@pytest.mark.anyio
async def test_create_offer():
set_funding_source("CoreLightningWallet")
invoice_offer = await create_offer(
wallet_id="10c8094f9d1c4ab4b9e3dfe964527297",
amount_sat=1000,
memo=description,
single_use=True,
)
"""
offer = decode(invoice_offer.bolt12)
assert offer.amount_msat == 1000000
assert offer.description == description
"""
await disable_offer(
wallet_id="10c8094f9d1c4ab4b9e3dfe964527297", offer_id=invoice_offer.offer_id
)
funding_source = get_funding_source()
status = await funding_source.get_offer_status(invoice_offer.offer_id)
assert isinstance(status, OfferStatus)
assert not status.active
assert not status.used