Skip to content

Commit e7b419b

Browse files
Merge branch 'feat/add_onchain'
2 parents 5d4a4f9 + c60ed73 commit e7b419b

14 files changed

Lines changed: 996 additions & 146 deletions

File tree

__init__.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from loguru import logger
55

66
from .crud import db
7-
from .tasks import wait_for_paid_invoices
7+
from .tasks import poll_onchain_payments, wait_for_paid_invoices
88
from .views import tpos_generic_router
99
from .views_api import tpos_api_router
1010
from .views_atm import tpos_atm_router
@@ -37,8 +37,11 @@ def tpos_stop():
3737
def tpos_start():
3838
from lnbits.tasks import create_permanent_unique_task
3939

40-
task = create_permanent_unique_task("ext_tpos", wait_for_paid_invoices)
41-
scheduled_tasks.append(task)
40+
invoice_task = create_permanent_unique_task("ext_tpos", wait_for_paid_invoices)
41+
onchain_task = create_permanent_unique_task(
42+
"ext_tpos_onchain", poll_onchain_payments
43+
)
44+
scheduled_tasks.extend([invoice_task, onchain_task])
4245

4346

4447
__all__ = [

crud.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from lnbits.helpers import urlsafe_short_hash
33

44
from .helpers import serialize_inventory_tags
5-
from .models import CreateTposData, LnurlCharge, Tpos, TposClean
5+
from .models import CreateTposData, LnurlCharge, Tpos, TposClean, TposPayment
66

77
db = Database("ext_tpos")
88

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

7171
async def delete_tpos(tpos_id: str) -> None:
7272
await db.execute("DELETE FROM tpos.pos WHERE id = :id", {"id": tpos_id})
73+
await db.execute("DELETE FROM tpos.payments WHERE tpos_id = :id", {"id": tpos_id})
74+
75+
76+
async def create_tpos_payment(payment: TposPayment) -> TposPayment:
77+
await db.insert("tpos.payments", payment)
78+
return payment
79+
80+
81+
async def get_tpos_payment(payment_id: str) -> TposPayment | None:
82+
return await db.fetchone(
83+
"SELECT * FROM tpos.payments WHERE id = :id",
84+
{"id": payment_id},
85+
TposPayment,
86+
)
87+
88+
89+
async def get_tpos_payment_by_hash(payment_hash: str) -> TposPayment | None:
90+
return await db.fetchone(
91+
"SELECT * FROM tpos.payments WHERE payment_hash = :payment_hash",
92+
{"payment_hash": payment_hash},
93+
TposPayment,
94+
)
95+
96+
97+
async def get_tpos_payment_by_onchain_address(address: str) -> TposPayment | None:
98+
return await db.fetchone(
99+
"SELECT * FROM tpos.payments WHERE onchain_address = :address",
100+
{"address": address},
101+
TposPayment,
102+
)
103+
104+
105+
async def get_pending_tpos_payments() -> list[TposPayment]:
106+
return await db.fetchall(
107+
"""
108+
SELECT * FROM tpos.payments
109+
WHERE paid = false AND onchain_address IS NOT NULL
110+
ORDER BY created_at ASC
111+
""",
112+
model=TposPayment,
113+
)
114+
115+
116+
async def get_latest_tpos_payments(tpos_id: str, limit: int = 5) -> list[TposPayment]:
117+
return await db.fetchall(
118+
f"""
119+
SELECT * FROM tpos.payments
120+
WHERE tpos_id = :tpos_id AND paid = true
121+
ORDER BY updated_at DESC LIMIT {int(limit)}
122+
""",
123+
{"tpos_id": tpos_id},
124+
TposPayment,
125+
)
126+
127+
128+
async def update_tpos_payment(payment: TposPayment) -> TposPayment:
129+
await db.update("tpos.payments", payment)
130+
return payment

migrations.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,3 +260,33 @@ async def m021_add_cash_settlement(db: Database):
260260
await db.execute("""
261261
ALTER TABLE tpos.pos ADD allow_cash_settlement BOOLEAN DEFAULT false;
262262
""")
263+
264+
265+
async def m022_add_onchain_settings_and_payments(db: Database):
266+
await db.execute("""
267+
ALTER TABLE tpos.pos ADD onchain_enabled BOOLEAN DEFAULT false;
268+
""")
269+
await db.execute("""
270+
ALTER TABLE tpos.pos ADD onchain_wallet_id TEXT NULL;
271+
""")
272+
await db.execute("""
273+
ALTER TABLE tpos.pos ADD onchain_zero_conf BOOLEAN DEFAULT true;
274+
""")
275+
await db.execute("""
276+
CREATE TABLE tpos.payments (
277+
id TEXT PRIMARY KEY,
278+
tpos_id TEXT NOT NULL,
279+
payment_hash TEXT NOT NULL UNIQUE,
280+
amount INTEGER NOT NULL DEFAULT 0,
281+
paid BOOLEAN DEFAULT false,
282+
payment_method TEXT NULL,
283+
onchain_address TEXT NULL,
284+
onchain_wallet_id TEXT NULL,
285+
onchain_zero_conf BOOLEAN DEFAULT true,
286+
mempool_endpoint TEXT NULL,
287+
balance INTEGER NOT NULL DEFAULT 0,
288+
pending INTEGER NOT NULL DEFAULT 0,
289+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
290+
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
291+
);
292+
""")

models.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ class CreateTposInvoice(BaseModel):
2828
internal_memo: str | None = Query(None, max_length=512)
2929
pay_in_fiat: bool = Query(False)
3030
fiat_method: str | None = Query(None)
31+
payment_method: str | None = Query(None)
3132
amount_fiat: float | None = Query(None, ge=0.0)
3233
tip_amount_fiat: float | None = Query(None, ge=0.0)
3334

@@ -72,6 +73,9 @@ class CreateTposData(BaseModel):
7273
stripe_card_payments: bool = False
7374
stripe_reader_id: str | None = None
7475
allow_cash_settlement: bool = Field(False)
76+
onchain_enabled: bool = Field(False)
77+
onchain_wallet_id: str | None = None
78+
onchain_zero_conf: bool = Field(True)
7579

7680
@validator("tax_default", pre=True, always=True)
7781
def default_tax_when_none(cls, v):
@@ -108,6 +112,9 @@ class TposClean(BaseModel):
108112
stripe_card_payments: bool = False
109113
stripe_reader_id: str | None = None
110114
allow_cash_settlement: bool = False
115+
onchain_enabled: bool = False
116+
onchain_wallet_id: str | None = None
117+
onchain_zero_conf: bool = True
111118

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

134141

142+
class TposPayment(BaseModel):
143+
id: str
144+
tpos_id: str
145+
payment_hash: str
146+
amount: int = 0
147+
paid: bool = False
148+
payment_method: str | None = None
149+
onchain_address: str | None = None
150+
onchain_wallet_id: str | None = None
151+
onchain_zero_conf: bool = True
152+
mempool_endpoint: str | None = None
153+
balance: int = 0
154+
pending: int = 0
155+
created_at: datetime = Field(default_factory=datetime.utcnow)
156+
updated_at: datetime = Field(default_factory=datetime.utcnow)
157+
158+
159+
class TposInvoiceResponse(BaseModel):
160+
payment_hash: str
161+
bolt11: str
162+
payment_request: str
163+
tpos_payment_id: str
164+
payment_options: list[str] = Field(default_factory=list)
165+
onchain_address: str | None = None
166+
onchain_amount_sat: int | None = None
167+
payment_method: str | None = None
168+
extra: dict[str, Any] = Field(default_factory=dict)
169+
170+
135171
class LnurlCharge(BaseModel):
136172
id: str
137173
tpos_id: str

services.py

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
from typing import Any
22

33
import httpx
4-
from lnbits.core.crud import get_wallet
4+
from lnbits.core.crud import (
5+
get_installed_extension,
6+
get_user_active_extensions_ids,
7+
get_wallet,
8+
)
59
from lnbits.core.models import User
610
from lnbits.helpers import create_access_token
711
from lnbits.settings import settings
@@ -121,6 +125,93 @@ def inventory_available_for_user(user: User | None) -> bool:
121125
return bool(user and "inventory" in (user.extensions or []))
122126

123127

128+
async def watchonly_available_for_user(user_id: str) -> bool:
129+
installed = await get_installed_extension("watchonly")
130+
if not installed or not installed.active:
131+
return False
132+
active_extensions = await get_user_active_extensions_ids(user_id)
133+
return "watchonly" in active_extensions
134+
135+
136+
async def fetch_watchonly_config(api_key: str) -> dict[str, Any]:
137+
async with httpx.AsyncClient() as client:
138+
resp = await client.get(
139+
url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/config",
140+
headers={"X-API-KEY": api_key},
141+
)
142+
resp.raise_for_status()
143+
return resp.json()
144+
145+
146+
async def fetch_watchonly_wallets(api_key: str, network: str) -> list[dict[str, Any]]:
147+
async with httpx.AsyncClient() as client:
148+
resp = await client.get(
149+
url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/wallet",
150+
headers={"X-API-KEY": api_key},
151+
params={"network": network},
152+
)
153+
resp.raise_for_status()
154+
return resp.json()
155+
156+
157+
async def fetch_watchonly_wallet(api_key: str, wallet_id: str) -> dict[str, Any]:
158+
async with httpx.AsyncClient() as client:
159+
resp = await client.get(
160+
url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/wallet/{wallet_id}",
161+
headers={"X-API-KEY": api_key},
162+
)
163+
resp.raise_for_status()
164+
return resp.json()
165+
166+
167+
async def fetch_onchain_address(api_key: str, wallet_id: str) -> dict[str, Any]:
168+
async with httpx.AsyncClient() as client:
169+
resp = await client.get(
170+
url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/address/{wallet_id}",
171+
headers={"X-API-KEY": api_key},
172+
)
173+
resp.raise_for_status()
174+
return resp.json()
175+
176+
177+
def normalize_mempool_endpoint(
178+
mempool_endpoint: str | None, onchain_address: str
179+
) -> str:
180+
endpoint = (mempool_endpoint or "https://mempool.space").rstrip("/")
181+
if "/testnet" in endpoint or "/signet" in endpoint:
182+
return endpoint
183+
if onchain_address.lower().startswith("tb1"):
184+
return f"{endpoint}/testnet"
185+
return endpoint
186+
187+
188+
async def fetch_onchain_balance(
189+
mempool_endpoint: str, onchain_address: str
190+
) -> dict[str, Any]:
191+
endpoint = normalize_mempool_endpoint(mempool_endpoint, onchain_address)
192+
async with httpx.AsyncClient() as client:
193+
resp = await client.get(f"{endpoint}/api/address/{onchain_address}/txs")
194+
resp.raise_for_status()
195+
data = resp.json()
196+
confirmed_txs = [tx for tx in data if tx["status"]["confirmed"]]
197+
unconfirmed_txs = [tx for tx in data if not tx["status"]["confirmed"]]
198+
return {
199+
"confirmed": sum_transactions(onchain_address, confirmed_txs),
200+
"unconfirmed": sum_transactions(onchain_address, unconfirmed_txs),
201+
"txids": [tx["txid"] for tx in data],
202+
}
203+
204+
205+
def sum_outputs(address: str, vouts: list[dict[str, Any]]) -> int:
206+
return sum(
207+
vout["value"] for vout in vouts if vout.get("scriptpubkey_address") == address
208+
)
209+
210+
211+
def sum_transactions(address: str, txs: list[dict[str, Any]]) -> int:
212+
return sum(sum_outputs(address, tx.get("vout", [])) for tx in txs)
213+
214+
124215
async def push_order_to_orders(
125216
user_id: str,
126217
payment,

0 commit comments

Comments
 (0)