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, payment_hash=data.payment_hash,
bolt11=data.bolt11, bolt11=data.bolt11,
amount=data.amount_msat, amount=data.amount_msat,
offer_id=data.offer_id,
memo=data.memo, memo=data.memo,
payer_note=data.payer_note,
preimage=data.preimage, preimage=data.preimage,
expiry=data.expiry, expiry=data.expiry,
webhook=data.webhook, 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): async def m033_update_payment_table(db: Connection):
await db.execute("ALTER TABLE apipayments ADD COLUMN fiat_provider TEXT") 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 payment_hash: str
bolt11: str bolt11: str
amount_msat: int amount_msat: int
offer_id: str | None = None
memo: str memo: str
payer_note: str | None = None
extra: dict | None = {} extra: dict | None = {}
preimage: str | None = None preimage: str | None = None
expiry: datetime | None = None expiry: datetime | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
webhook: str | None = None webhook: str | None = None
fee: int = 0 fee: int = 0
@@ -70,7 +74,9 @@ class Payment(BaseModel):
# payment_request: str | None # payment_request: str | None
fiat_provider: str | None = None fiat_provider: str | None = None
status: str = PaymentState.PENDING status: str = PaymentState.PENDING
offer_id: str | None = None
memo: str | None = None memo: str | None = None
payer_note: str | None = None
expiry: datetime | None = None expiry: datetime | None = None
webhook: str | None = None webhook: str | None = None
webhook_status: str | None = None webhook_status: str | None = None

View File

@@ -1,5 +1,6 @@
import asyncio import asyncio
import json import json
import secrets
import time import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Optional from typing import Optional
@@ -16,7 +17,7 @@ from lnbits.core.models import PaymentDailyStats, PaymentFilters
from lnbits.core.models.payments import CreateInvoice from lnbits.core.models.payments import CreateInvoice
from lnbits.db import Connection, Filters from lnbits.db import Connection, Filters
from lnbits.decorators import check_user_extension_access 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.fiat import get_fiat_provider
from lnbits.helpers import check_callback_url from lnbits.helpers import check_callback_url
from lnbits.settings import settings from lnbits.settings import settings
@@ -42,8 +43,19 @@ from ..crud import (
is_internal_status_success, is_internal_status_success,
update_payment, 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 ( from ..models import (
CreateOffer,
CreatePayment, CreatePayment,
Offer,
Payment, Payment,
PaymentState, PaymentState,
Wallet, Wallet,
@@ -54,6 +66,155 @@ payment_lock = asyncio.Lock()
wallets_payments_lock: dict[str, 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( async def pay_invoice(
*, *,
wallet_id: str, wallet_id: str,

View File

@@ -14,6 +14,12 @@ from lnbits.settings import settings
from .helpers import path_segments, template_renderer 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): class PaymentError(Exception):
def __init__(self, message: str, status: str = "pending"): def __init__(self, message: str, status: str = "pending"):
self.message = message self.message = message

View File

@@ -14,7 +14,9 @@ from lnbits.core.crud import (
update_payment, update_payment,
) )
from lnbits.core.models import Payment, PaymentState 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.settings import settings
from lnbits.wallets import get_funding_source 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(): for name, send_chan in invoice_listeners.items():
logger.trace(f"invoice listeners: sending to `{name}`") logger.trace(f"invoice listeners: sending to `{name}`")
await send_chan.put(payment) 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 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): class InvoiceResponse(NamedTuple):
ok: bool ok: bool
checking_id: str | None = None # payment_hash, rpc_id 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" 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]: async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
while settings.lnbits_running: while settings.lnbits_running:
for invoice in self.pending_invoices: for invoice in self.pending_invoices:

View File

@@ -1,4 +1,5 @@
import asyncio import asyncio
import random
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from secrets import token_urlsafe from secrets import token_urlsafe
from typing import Any, Optional from typing import Any, Optional
@@ -15,7 +16,12 @@ from lnbits.utils.crypto import random_secret_and_hash
from .base import ( from .base import (
Feature, Feature,
FetchInvoiceResponse,
InvoiceMainData,
InvoiceResponse, InvoiceResponse,
OfferErrorStatus,
OfferResponse,
OfferStatus,
PaymentFailedStatus, PaymentFailedStatus,
PaymentPendingStatus, PaymentPendingStatus,
PaymentResponse, PaymentResponse,
@@ -89,6 +95,218 @@ class CoreLightningWallet(Wallet):
logger.warning(f"Failed to connect, got: '{exc}'") logger.warning(f"Failed to connect, got: '{exc}'")
return StatusResponse(f"Unable to connect, got: '{exc}'", 0) 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( async def create_invoice(
self, self,
amount: int, amount: int,
@@ -205,6 +423,70 @@ class CoreLightningWallet(Wallet):
logger.warning(exc) logger.warning(exc)
return PaymentResponse(error_message=f"Payment failed: '{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: async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
try: try:
r: dict = self.ln.listinvoices(payment_hash=checking_id) # type: ignore 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