Skip to content
Merged
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
257 changes: 254 additions & 3 deletions app/backend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
from typing import Any, Optional
from urllib.parse import parse_qs, urlparse, quote

import sqlite3
from datetime import datetime, timezone
import dns.exception
import dns.resolver
import httpx
Expand Down Expand Up @@ -312,7 +314,7 @@ def get_cloudflare_config():
CONFIG_DIR = APP_DATA_DIR / "config"
CONFIG_JSON_PATH = Path(os.getenv("CONFIG_JSON_PATH", str(APP_DATA_DIR / "config.json")))
SECRETS_JSON_PATH = Path(os.getenv("SECRETS_JSON_PATH", str(CONFIG_DIR / "secrets.json")))

TX_DB_PATH = APP_DATA_DIR / "bolt12pay.db"

def _load_json_file(path: Path):
try:
Expand Down Expand Up @@ -1255,6 +1257,95 @@ def _read_macaroon_hex(path: str) -> str:
raise HTTPException(status_code=500, detail=f"macaroon file not found: {path}") from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=f"failed to read macaroon file: {exc}") from exc

async def _fetch_lnd_payments():
headers = {
"Grpc-Metadata-macaroon": _read_macaroon_hex(LND_MACAROON_PATH)
}

verify = False if LND_REST_INSECURE else LND_TLS_CERT_PATH

async with httpx.AsyncClient(timeout=30, verify=verify) as client:
response = await client.get(
f"{LND_REST_URL}/v1/payments?reversed=true&max_payments=50&include_incomplete=true",
headers=headers,
)

response.raise_for_status()
return response.json()

async def _sync_lnd_payments():
data = await _fetch_lnd_payments()

for payment in data.get("payments", []):
if payment.get("status") != "SUCCEEDED":
continue

_upsert_transaction(
payment_hash=payment["payment_hash"],
timestamp=int(payment.get("creation_date") or 0),
direction="outgoing",
payment_type="bolt11",
amount_sat=int(payment.get("value_sat") or 0),
fee_sat=int(payment.get("fee_sat") or 0),
status="settled",
)

async def _fetch_lnd_invoices():
headers = {
"Grpc-Metadata-macaroon": _read_macaroon_hex(LND_MACAROON_PATH)
}

verify = False if LND_REST_INSECURE else LND_TLS_CERT_PATH

async with httpx.AsyncClient(timeout=30, verify=verify) as client:
response = await client.get(
f"{LND_REST_URL}/v1/invoices?num_max_invoices=1000&reversed=true",
headers=headers,
)

response.raise_for_status()
return response.json()

async def _sync_lnd_invoices():
data = await _fetch_lnd_invoices()

for inv in data.get("invoices", []):
if not inv.get("settled"):
continue
print(
"SYNC INVOICE",
inv.get("memo"),
inv.get("settled"),
inv.get("r_hash"),
inv.get("amt_paid_sat"),
flush=True,
)

payment_hash = str(inv.get("r_hash") or "").strip()

if not payment_hash:
continue

print(
"UPSERT INVOICE",
payment_hash,
inv.get("amt_paid_sat"),
flush=True,
)
_upsert_transaction(
payment_hash=payment_hash,
timestamp=int(inv.get("settle_date") or inv.get("creation_date") or 0),
direction="incoming",
payment_type="bolt11",
amount_sat=int(inv.get("amt_paid_sat") or inv.get("value") or 0),
memo=inv.get("memo"),
status="settled",
source="lnd",
raw_json=inv,
)


def get_lnurl_base_domain():
cfg = load_config()
return (cfg.get("lnurl_base_domain") or "").strip().lower() or LNURL_BASE_DOMAIN
Expand All @@ -1264,6 +1355,115 @@ def get_lnurl_base_url():
cfg = load_config()
return (cfg.get("lnurl_base_url") or "").strip().rstrip("/") or LNURL_BASE_URL

def _tx_db():
APP_DATA_DIR.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(TX_DB_PATH)
conn.row_factory = sqlite3.Row
return conn


def _init_tx_db():
with _tx_db() as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS transactions (
payment_hash TEXT PRIMARY KEY,
timestamp INTEGER NOT NULL,
direction TEXT NOT NULL,
payment_type TEXT,
amount_sat INTEGER,
fee_sat INTEGER,
alias TEXT,
destination TEXT,
memo TEXT,
status TEXT,
source TEXT DEFAULT 'lnd',
raw_json TEXT,
updated_at INTEGER NOT NULL
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_transactions_timestamp
ON transactions(timestamp)
""")


def _utc_now_ts() -> int:
return int(datetime.now(timezone.utc).timestamp())


def _upsert_transaction(
*,
payment_hash: str,
timestamp: int | None,
direction: str,
payment_type: str | None = None,
amount_sat: int | None = None,
fee_sat: int | None = None,
alias: str | None = None,
destination: str | None = None,
memo: str | None = None,
status: str | None = None,
source: str = "lnd",
raw_json: dict | None = None,
):
payment_hash = (payment_hash or "").strip()
if not payment_hash:
return

now = _utc_now_ts()
ts = int(timestamp or now)

with _tx_db() as conn:
conn.execute("""
INSERT INTO transactions (
payment_hash, timestamp, direction, payment_type,
amount_sat, fee_sat, alias, destination, memo,
status, source, raw_json, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(payment_hash) DO UPDATE SET
timestamp = COALESCE(excluded.timestamp, transactions.timestamp),
direction = COALESCE(excluded.direction, transactions.direction),
payment_type = COALESCE(excluded.payment_type, transactions.payment_type),
amount_sat = COALESCE(excluded.amount_sat, transactions.amount_sat),
fee_sat = COALESCE(excluded.fee_sat, transactions.fee_sat),
alias = COALESCE(excluded.alias, transactions.alias),
destination = COALESCE(excluded.destination, transactions.destination),
memo = COALESCE(excluded.memo, transactions.memo),
status = COALESCE(excluded.status, transactions.status),
source = COALESCE(excluded.source, transactions.source),
raw_json = COALESCE(excluded.raw_json, transactions.raw_json),
updated_at = excluded.updated_at
""", (
payment_hash,
ts,
direction,
payment_type,
amount_sat,
fee_sat,
alias,
destination,
memo,
status,
source,
json.dumps(raw_json, separators=(",", ":"), ensure_ascii=False) if raw_json else None,
now,
))


def _list_transactions(limit: int = 100):

safe_limit = max(1, min(int(limit or 100), 500))

with _tx_db() as conn:
rows = conn.execute("""
SELECT *
FROM transactions
ORDER BY timestamp DESC
LIMIT ?
""", (safe_limit,)).fetchall()

return [dict(row) for row in rows]
async def _create_bolt11_invoice(
*,
amount_sat: int,
Expand Down Expand Up @@ -1923,6 +2123,23 @@ def lnurl_for_address(address: str) -> LnurlInfoResponse:
lnurlp_url=lnurlp_url,
)

@app.get("/api/debug/lnd-payments")
async def debug_lnd_payments(request: StarletteRequest):
if _is_pay_ui_enabled() and not _is_pay_session_valid(request):
raise HTTPException(status_code=401, detail="Authentication required")
return await _fetch_lnd_payments()


@app.get("/api/debug/lnd-invoices")
async def debug_lnd_invoices(request: StarletteRequest):
if _is_pay_ui_enabled() and not _is_pay_session_valid(request):
raise HTTPException(status_code=401, detail="Authentication required")
return await _fetch_lnd_invoices()

@app.get("/api/debug/sync-invoices")
async def api_debug_sync_invoices():
await _sync_lnd_invoices()
return {"ok": True}

@app.get("/.well-known/lnurlp/{username}", response_model=LnurlPayMetadataResponse)
def lnurl_pay_metadata(username: str) -> LnurlPayMetadataResponse:
Expand Down Expand Up @@ -2141,6 +2358,7 @@ def pay_offer(payload: PayOfferRequest, request: StarletteRequest) -> PayOfferRe

raw_output = _run_command(args)
return PayOfferResponse(resolved_offer=normalized_offer, raw_output=raw_output)

@app.post("/api/pay-address", response_model=PayOfferResponse)
async def pay_address(payload: PayAddressRequest, request: StarletteRequest) -> PayOfferResponse:
_require_csrf(request)
Expand All @@ -2167,7 +2385,6 @@ async def pay_address(payload: PayAddressRequest, request: StarletteRequest) ->
indent=2,
ensure_ascii=False,
)

return PayOfferResponse(resolved_offer=normalized_offer, raw_output=raw_output)

except HTTPException as exc:
Expand All @@ -2185,6 +2402,25 @@ async def pay_address(payload: PayAddressRequest, request: StarletteRequest) ->
pay_result = await _pay_bolt11_invoice(
payment_request=lnurl_result["payment_request"],
)
payment_hash = str(pay_result.get("payment_hash") or "").strip()

if payment_hash:
_upsert_transaction(
payment_hash=payment_hash,
timestamp=_utc_now_ts(),
direction="outgoing",
payment_type="lnurl",
amount_sat=payload.amount_sat,
destination=target,
memo=payload.payer_note,
status="settled",
source="bolt12pay",
raw_json={
"mode": "lnurl",
"target": target,
"payment_result": pay_result,
},
)

raw_output = json.dumps(
{
Expand All @@ -2196,12 +2432,13 @@ async def pay_address(payload: PayAddressRequest, request: StarletteRequest) ->
},
indent=2,
ensure_ascii=False,
)
)

return PayOfferResponse(
resolved_offer=target,
raw_output=raw_output,
)

@app.post("/api/pay-bolt11", response_model=PayOfferResponse)
async def pay_bolt11(payload: PayBolt11Request) -> PayOfferResponse:

Expand All @@ -2226,6 +2463,14 @@ async def pay_bolt11(payload: PayBolt11Request) -> PayOfferResponse:
raw_output=raw_output,
)

@app.get("/api/debug/sync-payments")
async def api_debug_sync_payments(request: StarletteRequest):
if _is_pay_ui_enabled() and not _is_pay_session_valid(request):
raise HTTPException(status_code=401, detail="Authentication required")

await _sync_lnd_payments()
return {"ok": True}

class DecodeBolt11Request(BaseModel):
invoice: str = Field(min_length=10, description="BOLT11 invoice like lnbc...")

Expand Down Expand Up @@ -4712,6 +4957,7 @@ async def _zap_publisher_loop():

@app.on_event("startup")
async def startup_background_tasks():
_init_tx_db()
app.state.zap_task = asyncio.create_task(_zap_publisher_loop())
app.state.nwc_task = asyncio.create_task(start_nwc_runtime())
print("zap publisher loop started", flush=True)
Expand All @@ -4723,6 +4969,11 @@ async def api_admin_nostr_status(request: StarletteRequest):
raise HTTPException(status_code=401, detail="Authentication required")
return _get_nostr_admin_status()

@app.get("/api/tx-history")
async def api_tx_history(request: StarletteRequest, limit: int = 100):
if _is_pay_ui_enabled() and not _is_pay_session_valid(request):
raise HTTPException(status_code=401, detail="Authentication required")
return {"transactions": _list_transactions(limit)}


def load_secrets() -> dict:
Expand Down
Loading