mirror of
https://github.com/lnbits/lnbits.git
synced 2025-09-25 11:14:02 +02:00
feat: initial support for bolt12
rebase of #3092 special thanks to @21M4TW
This commit is contained in:
225
lnbits/core/crud/offers.py
Normal file
225
lnbits/core/crud/offers.py
Normal 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},
|
||||
)
|
@@ -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,
|
||||
|
@@ -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)")
|
||||
|
100
lnbits/core/models/offers.py
Normal file
100
lnbits/core/models/offers.py
Normal 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
|
@@ -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
|
||||
|
@@ -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,
|
||||
|
@@ -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
|
||||
|
@@ -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)
|
||||
|
@@ -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:
|
||||
|
@@ -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
|
||||
|
34
tests/regtest/test_services_create_offer.py
Normal file
34
tests/regtest/test_services_create_offer.py
Normal 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
|
Reference in New Issue
Block a user