"""lightning.py - Lightning rail: API keys, prepaid top-ups and monthly plans in sats. Backends (LN_BACKEND): "phoenixd" (ACINQ, limited-access password), "lnd" (LND REST, invoice-only macaroon) or "lnbits" (invoice/read key). Only invoice create + lookup are used: no payments, no wallet spending. Stdlib only. """ import base64, hashlib, json, os, secrets, sqlite3, ssl, threading, time, urllib.error, urllib.parse, urllib.request DB_PATH = os.environ.get("LEDGER_DB", "btc402.sqlite") PLANS = { # monthly plans: price in sats, calls included per 30 days "basic": {"sats": int(os.environ.get("PLAN_BASIC_SATS", "5000")), "calls": 10000}, "pro": {"sats": int(os.environ.get("PLAN_PRO_SATS", "20000")), "calls": 100000}, } _lock = threading.Lock() def db(): c = sqlite3.connect(DB_PATH, check_same_thread=False) c.executescript(""" CREATE TABLE IF NOT EXISTS keys(key_hash TEXT PRIMARY KEY, balance INTEGER DEFAULT 0, plan TEXT, plan_until INTEGER DEFAULT 0, plan_calls INTEGER DEFAULT 0, created INTEGER); CREATE TABLE IF NOT EXISTS invoices(payment_hash TEXT PRIMARY KEY, key_hash TEXT, sats INTEGER, kind TEXT, plan TEXT, bolt11 TEXT, settled INTEGER DEFAULT 0, created INTEGER); CREATE TABLE IF NOT EXISTS ledger(id INTEGER PRIMARY KEY, ts INTEGER, key_hash TEXT, delta INTEGER, reason TEXT);""") return c CONN = None def conn(): global CONN if CONN is None: CONN = db() return CONN def h(key): # API keys are stored only as SHA-256 hashes return hashlib.sha256(key.encode()).hexdigest() # ---------------- backends ---------------- def _http(method, url, body=None, headers=None, ctx=None): data = json.dumps(body).encode() if body is not None else None req = urllib.request.Request(url, data=data, method=method, headers={"Content-Type": "application/json", **(headers or {})}) with urllib.request.urlopen(req, timeout=20, context=ctx) as r: return json.loads(r.read()) class LNbits: def __init__(self): self.url = os.environ["LNBITS_URL"].rstrip("/") self.key = os.environ["LNBITS_INVOICE_KEY"] def create(self, sats, memo): r = _http("POST", f"{self.url}/api/v1/payments", {"out": False, "amount": sats, "memo": memo}, {"X-Api-Key": self.key}) return r["payment_hash"], r.get("payment_request") or r.get("bolt11") def paid(self, payment_hash): return bool(_http("GET", f"{self.url}/api/v1/payments/{payment_hash}", None, {"X-Api-Key": self.key}).get("paid")) class LND: def __init__(self): self.url = os.environ["LND_REST_URL"].rstrip("/") with open(os.environ["LND_MACAROON_PATH"], "rb") as f: # use invoice.macaroon self.mac = f.read().hex() tls = os.environ.get("LND_TLS_CERT_PATH") self.ctx = ssl.create_default_context(cafile=tls) if tls else None if self.ctx: self.ctx.check_hostname = False def create(self, sats, memo): r = _http("POST", f"{self.url}/v1/invoices", {"value": str(sats), "memo": memo, "expiry": "3600"}, {"Grpc-Metadata-macaroon": self.mac}, self.ctx) return base64.b64decode(r["r_hash"]).hex(), r["payment_request"] def paid(self, payment_hash): r = _http("GET", f"{self.url}/v1/invoice/{payment_hash}", None, {"Grpc-Metadata-macaroon": self.mac}, self.ctx) return r.get("state") == "SETTLED" or bool(r.get("settled")) class Phoenixd: """ACINQ phoenixd HTTP API. Uses the limited-access password (create/read invoices, cannot pay).""" def __init__(self): self.url = os.environ.get("PHOENIXD_URL", "http://127.0.0.1:9740").rstrip("/") pw = os.environ.get("PHOENIXD_PASSWORD", "") conf = os.path.expanduser(os.environ.get("PHOENIXD_CONF", "~/.phoenix/phoenix.conf")) if not pw and os.path.isfile(conf): for line in open(conf): if line.startswith("http-password-limited-access="): pw = line.split("=", 1)[1].strip() if not pw: raise RuntimeError("phoenixd password not found") self.auth = "Basic " + base64.b64encode(f":{pw}".encode()).decode() def _req(self, method, path, form=None): data = urllib.parse.urlencode(form).encode() if form else None req = urllib.request.Request(self.url + path, data=data, method=method, headers={"Authorization": self.auth}) with urllib.request.urlopen(req, timeout=20) as r: return json.loads(r.read()) def create(self, sats, memo): r = self._req("POST", "/createinvoice", {"amountSat": sats, "description": memo}) return r["paymentHash"], r["serialized"] def paid(self, payment_hash): try: return bool(self._req("GET", f"/payments/incoming/{payment_hash}").get("isPaid")) except urllib.error.HTTPError as e: if e.code == 404: return False raise _backend = None def backend(): global _backend if _backend is None: name = os.environ.get("LN_BACKEND", "lnbits").lower() if name == "phoenixd": _backend = Phoenixd(); return _backend if not (os.environ.get("LND_REST_URL") if name == "lnd" else os.environ.get("LNBITS_URL")): raise RuntimeError("Lightning backend not configured (set LNBITS_URL/LNBITS_INVOICE_KEY or LND_* in btc402.env)") _backend = LND() if name == "lnd" else LNbits() return _backend # ---------------- API ---------------- def create_key(): key = "btk_" + secrets.token_urlsafe(24) with _lock: conn().execute("INSERT INTO keys(key_hash,created) VALUES(?,?)", (h(key), int(time.time()))) conn().commit() return {"key": key, "balance_sats": 0, "note": "Store this key now; only its hash is kept."} def _require_key(key): row = conn().execute("SELECT key_hash FROM keys WHERE key_hash=?", (h(key or ""),)).fetchone() if not row: raise ValueError("unknown api key") return row[0] def _invoice(key, sats, kind, plan=None): kh = _require_key(key) if sats <= 0 or sats > int(os.environ.get("MAX_TOPUP_SATS", "1000000")): raise ValueError("invalid sats amount") ph, bolt11 = backend().create(sats, f"btc402 {kind}" + (f" {plan}" if plan else "")) with _lock: conn().execute("INSERT INTO invoices VALUES(?,?,?,?,?,?,0,?)", (ph, kh, sats, kind, plan, bolt11, int(time.time()))) conn().commit() return {"payment_hash": ph, "bolt11": bolt11, "sats": sats, "kind": kind, "plan": plan, "poll": f"/topup/{ph}"} def topup(key, sats): return _invoice(key, int(sats), "topup") def subscribe(key, plan): if plan not in PLANS: raise ValueError(f"unknown plan; choose {list(PLANS)}") return _invoice(key, PLANS[plan]["sats"], "subscription", plan) def poll(payment_hash, on_settle=None): """Check an invoice; on first settlement credit the key exactly once.""" row = conn().execute("SELECT key_hash,sats,kind,plan,settled FROM invoices WHERE payment_hash=?", (payment_hash,)).fetchone() if not row: raise ValueError("unknown invoice") kh, sats, kind, plan, settled = row if not settled and backend().paid(payment_hash): with _lock: cur = conn().execute("UPDATE invoices SET settled=1 WHERE payment_hash=? AND settled=0", (payment_hash,)) if cur.rowcount == 1: now = int(time.time()) if kind == "topup": conn().execute("UPDATE keys SET balance=balance+? WHERE key_hash=?", (sats, kh)) else: conn().execute("UPDATE keys SET plan=?, plan_until=MAX(plan_until,?)+30*86400, " "plan_calls=? WHERE key_hash=?", (plan, now, PLANS[plan]["calls"], kh)) conn().execute("INSERT INTO ledger(ts,key_hash,delta,reason) VALUES(?,?,?,?)", (now, kh, sats if kind == "topup" else 0, f"{kind}:{payment_hash}")) conn().commit() settled = 1 if on_settle: on_settle(payment_hash, sats, kind) return {"payment_hash": payment_hash, "settled": bool(settled), **account(kh)} def account(kh): b, plan, until, calls = conn().execute( "SELECT balance,plan,plan_until,plan_calls FROM keys WHERE key_hash=?", (kh,)).fetchone() return {"balance_sats": b, "plan": plan if until > time.time() else None, "plan_until": until, "plan_calls_left": calls} def debit(key, price_sats, reason): """Charge one call: active plan first, else sat balance. Returns (ok, info).""" kh = h(key) with _lock: row = conn().execute("SELECT balance,plan_until,plan_calls FROM keys WHERE key_hash=?", (kh,)).fetchone() if not row: return False, "unknown api key" bal, until, calls = row if until > time.time() and calls > 0: conn().execute("UPDATE keys SET plan_calls=plan_calls-1 WHERE key_hash=?", (kh,)) delta = 0 elif bal >= price_sats: conn().execute("UPDATE keys SET balance=balance-? WHERE key_hash=?", (price_sats, kh)) delta = -price_sats else: return False, "insufficient balance; POST /topup or /subscribe" conn().execute("INSERT INTO ledger(ts,key_hash,delta,reason) VALUES(?,?,?,?)", (int(time.time()), kh, delta, reason)) conn().commit() return True, account(kh)