diff --git a/lnbits/core/crud/offers.py b/lnbits/core/crud/offers.py new file mode 100644 index 000000000..da1929369 --- /dev/null +++ b/lnbits/core/crud/offers.py @@ -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}, + ) diff --git a/lnbits/core/crud/payments.py b/lnbits/core/crud/payments.py index d6e821060..218b91ada 100644 --- a/lnbits/core/crud/payments.py +++ b/lnbits/core/crud/payments.py @@ -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, diff --git a/lnbits/core/migrations.py b/lnbits/core/migrations.py index 57a81d588..1b389ee3c 100644 --- a/lnbits/core/migrations.py +++ b/lnbits/core/migrations.py @@ -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)") diff --git a/lnbits/core/models/offers.py b/lnbits/core/models/offers.py new file mode 100644 index 000000000..e0b6fc911 --- /dev/null +++ b/lnbits/core/models/offers.py @@ -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 diff --git a/lnbits/core/models/payments.py b/lnbits/core/models/payments.py index 068613860..f13a7e3d4 100644 --- a/lnbits/core/models/payments.py +++ b/lnbits/core/models/payments.py @@ -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 diff --git a/lnbits/core/services/payments.py b/lnbits/core/services/payments.py index 07d370bd5..6cf6a2810 100644 --- a/lnbits/core/services/payments.py +++ b/lnbits/core/services/payments.py @@ -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, diff --git a/lnbits/exceptions.py b/lnbits/exceptions.py index 28bebf045..efa94150e 100644 --- a/lnbits/exceptions.py +++ b/lnbits/exceptions.py @@ -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 diff --git a/lnbits/tasks.py b/lnbits/tasks.py index 37f12c316..3eb760fa4 100644 --- a/lnbits/tasks.py +++ b/lnbits/tasks.py @@ -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) diff --git a/lnbits/wallets/base.py b/lnbits/wallets/base.py index 5fa404292..951a42a8d 100644 --- a/lnbits/wallets/base.py +++ b/lnbits/wallets/base.py @@ -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: diff --git a/lnbits/wallets/corelightning.py b/lnbits/wallets/corelightning.py index bc81a773a..4fb8f8d81 100644 --- a/lnbits/wallets/corelightning.py +++ b/lnbits/wallets/corelightning.py @@ -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 diff --git a/tests/regtest/test_services_create_offer.py b/tests/regtest/test_services_create_offer.py new file mode 100644 index 000000000..f1b82f044 --- /dev/null +++ b/tests/regtest/test_services_create_offer.py @@ -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