Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from loguru import logger

from .crud import db
from .tasks import wait_for_paid_invoices
from .tasks import poll_onchain_payments, wait_for_paid_invoices
from .views import tpos_generic_router
from .views_api import tpos_api_router
from .views_atm import tpos_atm_router
Expand Down Expand Up @@ -37,8 +37,11 @@ def tpos_stop():
def tpos_start():
from lnbits.tasks import create_permanent_unique_task

task = create_permanent_unique_task("ext_tpos", wait_for_paid_invoices)
scheduled_tasks.append(task)
invoice_task = create_permanent_unique_task("ext_tpos", wait_for_paid_invoices)
onchain_task = create_permanent_unique_task(
"ext_tpos_onchain", poll_onchain_payments
)
scheduled_tasks.extend([invoice_task, onchain_task])


__all__ = [
Expand Down
60 changes: 59 additions & 1 deletion crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from lnbits.helpers import urlsafe_short_hash

from .helpers import serialize_inventory_tags
from .models import CreateTposData, LnurlCharge, Tpos, TposClean
from .models import CreateTposData, LnurlCharge, Tpos, TposClean, TposPayment

db = Database("ext_tpos")

Expand Down Expand Up @@ -70,3 +70,61 @@ async def get_tposs(wallet_ids: str | list[str]) -> list[Tpos]:

async def delete_tpos(tpos_id: str) -> None:
await db.execute("DELETE FROM tpos.pos WHERE id = :id", {"id": tpos_id})
await db.execute("DELETE FROM tpos.payments WHERE tpos_id = :id", {"id": tpos_id})


async def create_tpos_payment(payment: TposPayment) -> TposPayment:
await db.insert("tpos.payments", payment)
return payment


async def get_tpos_payment(payment_id: str) -> TposPayment | None:
return await db.fetchone(
"SELECT * FROM tpos.payments WHERE id = :id",
{"id": payment_id},
TposPayment,
)


async def get_tpos_payment_by_hash(payment_hash: str) -> TposPayment | None:
return await db.fetchone(
"SELECT * FROM tpos.payments WHERE payment_hash = :payment_hash",
{"payment_hash": payment_hash},
TposPayment,
)


async def get_tpos_payment_by_onchain_address(address: str) -> TposPayment | None:
return await db.fetchone(
"SELECT * FROM tpos.payments WHERE onchain_address = :address",
{"address": address},
TposPayment,
)


async def get_pending_tpos_payments() -> list[TposPayment]:
return await db.fetchall(
"""
SELECT * FROM tpos.payments
WHERE paid = false AND onchain_address IS NOT NULL
ORDER BY created_at ASC
""",
model=TposPayment,
)


async def get_latest_tpos_payments(tpos_id: str, limit: int = 5) -> list[TposPayment]:
return await db.fetchall(
f"""
SELECT * FROM tpos.payments
WHERE tpos_id = :tpos_id AND paid = true
ORDER BY updated_at DESC LIMIT {int(limit)}
""",
{"tpos_id": tpos_id},
TposPayment,
)


async def update_tpos_payment(payment: TposPayment) -> TposPayment:
await db.update("tpos.payments", payment)
return payment
30 changes: 30 additions & 0 deletions migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,3 +260,33 @@ async def m021_add_cash_settlement(db: Database):
await db.execute("""
ALTER TABLE tpos.pos ADD allow_cash_settlement BOOLEAN DEFAULT false;
""")


async def m022_add_onchain_settings_and_payments(db: Database):
await db.execute("""
ALTER TABLE tpos.pos ADD onchain_enabled BOOLEAN DEFAULT false;
""")
await db.execute("""
ALTER TABLE tpos.pos ADD onchain_wallet_id TEXT NULL;
""")
await db.execute("""
ALTER TABLE tpos.pos ADD onchain_zero_conf BOOLEAN DEFAULT true;
""")
await db.execute("""
CREATE TABLE tpos.payments (
id TEXT PRIMARY KEY,
tpos_id TEXT NOT NULL,
payment_hash TEXT NOT NULL UNIQUE,
amount INTEGER NOT NULL DEFAULT 0,
paid BOOLEAN DEFAULT false,
payment_method TEXT NULL,
onchain_address TEXT NULL,
onchain_wallet_id TEXT NULL,
onchain_zero_conf BOOLEAN DEFAULT true,
mempool_endpoint TEXT NULL,
balance INTEGER NOT NULL DEFAULT 0,
pending INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
""")
36 changes: 36 additions & 0 deletions models.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class CreateTposInvoice(BaseModel):
internal_memo: str | None = Query(None, max_length=512)
pay_in_fiat: bool = Query(False)
fiat_method: str | None = Query(None)
payment_method: str | None = Query(None)
amount_fiat: float | None = Query(None, ge=0.0)
tip_amount_fiat: float | None = Query(None, ge=0.0)

Expand Down Expand Up @@ -72,6 +73,9 @@ class CreateTposData(BaseModel):
stripe_card_payments: bool = False
stripe_reader_id: str | None = None
allow_cash_settlement: bool = Field(False)
onchain_enabled: bool = Field(False)
onchain_wallet_id: str | None = None
onchain_zero_conf: bool = Field(True)

@validator("tax_default", pre=True, always=True)
def default_tax_when_none(cls, v):
Expand Down Expand Up @@ -108,6 +112,9 @@ class TposClean(BaseModel):
stripe_card_payments: bool = False
stripe_reader_id: str | None = None
allow_cash_settlement: bool = False
onchain_enabled: bool = False
onchain_wallet_id: str | None = None
onchain_zero_conf: bool = True

@property
def withdraw_maximum(self) -> int:
Expand All @@ -132,6 +139,35 @@ class Tpos(TposClean, BaseModel):
tip_wallet: str | None = None


class TposPayment(BaseModel):
id: str
tpos_id: str
payment_hash: str
amount: int = 0
paid: bool = False
payment_method: str | None = None
onchain_address: str | None = None
onchain_wallet_id: str | None = None
onchain_zero_conf: bool = True
mempool_endpoint: str | None = None
balance: int = 0
pending: int = 0
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)


class TposInvoiceResponse(BaseModel):
payment_hash: str
bolt11: str
payment_request: str
tpos_payment_id: str
payment_options: list[str] = Field(default_factory=list)
onchain_address: str | None = None
onchain_amount_sat: int | None = None
payment_method: str | None = None
extra: dict[str, Any] = Field(default_factory=dict)


class LnurlCharge(BaseModel):
id: str
tpos_id: str
Expand Down
93 changes: 92 additions & 1 deletion services.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
from typing import Any

import httpx
from lnbits.core.crud import get_wallet
from lnbits.core.crud import (
get_installed_extension,
get_user_active_extensions_ids,
get_wallet,
)
from lnbits.core.models import User
from lnbits.helpers import create_access_token
from lnbits.settings import settings
Expand Down Expand Up @@ -121,6 +125,93 @@ def inventory_available_for_user(user: User | None) -> bool:
return bool(user and "inventory" in (user.extensions or []))


async def watchonly_available_for_user(user_id: str) -> bool:
installed = await get_installed_extension("watchonly")
if not installed or not installed.active:
return False
active_extensions = await get_user_active_extensions_ids(user_id)
return "watchonly" in active_extensions


async def fetch_watchonly_config(api_key: str) -> dict[str, Any]:
async with httpx.AsyncClient() as client:
resp = await client.get(
url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/config",
headers={"X-API-KEY": api_key},
)
resp.raise_for_status()
return resp.json()


async def fetch_watchonly_wallets(api_key: str, network: str) -> list[dict[str, Any]]:
async with httpx.AsyncClient() as client:
resp = await client.get(
url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/wallet",
headers={"X-API-KEY": api_key},
params={"network": network},
)
resp.raise_for_status()
return resp.json()


async def fetch_watchonly_wallet(api_key: str, wallet_id: str) -> dict[str, Any]:
async with httpx.AsyncClient() as client:
resp = await client.get(
url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/wallet/{wallet_id}",
headers={"X-API-KEY": api_key},
)
resp.raise_for_status()
return resp.json()


async def fetch_onchain_address(api_key: str, wallet_id: str) -> dict[str, Any]:
async with httpx.AsyncClient() as client:
resp = await client.get(
url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/address/{wallet_id}",
headers={"X-API-KEY": api_key},
)
resp.raise_for_status()
return resp.json()


def normalize_mempool_endpoint(
mempool_endpoint: str | None, onchain_address: str
) -> str:
endpoint = (mempool_endpoint or "https://mempool.space").rstrip("/")
if "/testnet" in endpoint or "/signet" in endpoint:
return endpoint
if onchain_address.lower().startswith("tb1"):
return f"{endpoint}/testnet"
return endpoint


async def fetch_onchain_balance(
mempool_endpoint: str, onchain_address: str
) -> dict[str, Any]:
endpoint = normalize_mempool_endpoint(mempool_endpoint, onchain_address)
async with httpx.AsyncClient() as client:
resp = await client.get(f"{endpoint}/api/address/{onchain_address}/txs")
resp.raise_for_status()
data = resp.json()
confirmed_txs = [tx for tx in data if tx["status"]["confirmed"]]
unconfirmed_txs = [tx for tx in data if not tx["status"]["confirmed"]]
return {
"confirmed": sum_transactions(onchain_address, confirmed_txs),
"unconfirmed": sum_transactions(onchain_address, unconfirmed_txs),
"txids": [tx["txid"] for tx in data],
}


def sum_outputs(address: str, vouts: list[dict[str, Any]]) -> int:
return sum(
vout["value"] for vout in vouts if vout.get("scriptpubkey_address") == address
)


def sum_transactions(address: str, txs: list[dict[str, Any]]) -> int:
return sum(sum_outputs(address, tx.get("vout", [])) for tx in txs)


async def push_order_to_orders(
user_id: str,
payment,
Expand Down
Loading
Loading