l402kit 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,30 @@
1
+ node_modules/
2
+ dist/
3
+ *.js.map
4
+ *.d.ts.map
5
+
6
+ # credentials — never commit
7
+ .env
8
+ .env.local
9
+ .env.*.local
10
+ CREDENTIALS.md
11
+ OPERATIONS.md
12
+
13
+ # claude code local settings (may contain api keys)
14
+ .claude/
15
+
16
+ # supabase temp (contains project ref and db urls)
17
+ supabase/.temp/
18
+
19
+ # python cache
20
+ __pycache__/
21
+ *.pyc
22
+ *.pyo
23
+ python/.env
24
+
25
+ # test files
26
+ test-server.ts
27
+ test-invoice.sh
28
+
29
+ # phoenixd (old infra, not part of npm package)
30
+ phoenixd/
l402kit-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,37 @@
1
+ Metadata-Version: 2.4
2
+ Name: l402kit
3
+ Version: 0.1.0
4
+ Summary: Lightning Network L402 payment middleware for FastAPI and Flask
5
+ Project-URL: Homepage, https://github.com/shinydapps/l402-kit
6
+ License: MIT
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: httpx>=0.27
9
+ Provides-Extra: fastapi
10
+ Requires-Dist: fastapi>=0.110; extra == 'fastapi'
11
+ Provides-Extra: flask
12
+ Requires-Dist: flask>=3.0; extra == 'flask'
13
+ Description-Content-Type: text/markdown
14
+
15
+ # l402kit
16
+
17
+ **Bitcoin Lightning payment middleware for FastAPI and Flask. 3 lines of code.**
18
+
19
+ ```bash
20
+ pip install l402kit
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ```python
26
+ from l402kit import l402_required, BlinkProvider
27
+
28
+ lightning = BlinkProvider(api_key="...", wallet_id="...")
29
+
30
+ @app.get("/premium")
31
+ @l402_required(price_sats=100, lightning=lightning)
32
+ async def premium(request: Request):
33
+ return {"data": "You paid 100 sats!"}
34
+ ```
35
+
36
+ Full docs: https://shinydapps.mintlify.app
37
+ GitHub: https://github.com/ShinyDapps/l402-kit
@@ -0,0 +1,23 @@
1
+ # l402kit
2
+
3
+ **Bitcoin Lightning payment middleware for FastAPI and Flask. 3 lines of code.**
4
+
5
+ ```bash
6
+ pip install l402kit
7
+ ```
8
+
9
+ ## Usage
10
+
11
+ ```python
12
+ from l402kit import l402_required, BlinkProvider
13
+
14
+ lightning = BlinkProvider(api_key="...", wallet_id="...")
15
+
16
+ @app.get("/premium")
17
+ @l402_required(price_sats=100, lightning=lightning)
18
+ async def premium(request: Request):
19
+ return {"data": "You paid 100 sats!"}
20
+ ```
21
+
22
+ Full docs: https://shinydapps.mintlify.app
23
+ GitHub: https://github.com/ShinyDapps/l402-kit
@@ -0,0 +1,22 @@
1
+ import os
2
+ from fastapi import FastAPI, Request
3
+ from l402kit import l402_required, BlinkProvider
4
+ from dotenv import load_dotenv
5
+
6
+ load_dotenv()
7
+
8
+ app = FastAPI()
9
+
10
+ lightning = BlinkProvider(
11
+ api_key=os.environ["BLINK_API_KEY"],
12
+ wallet_id=os.environ["BLINK_WALLET_ID"],
13
+ )
14
+
15
+ @app.get("/")
16
+ async def root():
17
+ return {"message": "L402-kit Python SDK. Try GET /premium"}
18
+
19
+ @app.get("/premium")
20
+ @l402_required(price_sats=100, lightning=lightning)
21
+ async def premium(request: Request):
22
+ return {"data": "Conteúdo premium — você pagou 100 sats!"}
@@ -0,0 +1,15 @@
1
+ from .middleware import l402_required
2
+ from .providers.blink import BlinkProvider
3
+ from .providers.opennode import OpenNodeProvider
4
+ from .providers.lnbits import LNbitsProvider
5
+ from .types import LightningProvider, Invoice
6
+
7
+ __version__ = "0.1.0"
8
+ __all__ = [
9
+ "l402_required",
10
+ "BlinkProvider",
11
+ "OpenNodeProvider",
12
+ "LNbitsProvider",
13
+ "LightningProvider",
14
+ "Invoice",
15
+ ]
@@ -0,0 +1,100 @@
1
+ import functools
2
+ from typing import Callable
3
+ from .types import LightningProvider
4
+ from .verify import verify_token
5
+ from .replay import check_and_mark_preimage
6
+
7
+
8
+ def l402_required(price_sats: int, lightning: LightningProvider):
9
+ """
10
+ Decorator for FastAPI and Flask routes.
11
+
12
+ FastAPI:
13
+ @app.get("/premium")
14
+ @l402_required(price_sats=100, lightning=blink)
15
+ async def premium(): ...
16
+
17
+ Flask:
18
+ @app.route("/premium")
19
+ @l402_required(price_sats=100, lightning=blink)
20
+ def premium(): ...
21
+ """
22
+ def decorator(func: Callable):
23
+ @functools.wraps(func)
24
+ async def fastapi_wrapper(*args, **kwargs):
25
+ # FastAPI: request comes via kwargs or dependency injection
26
+ request = kwargs.get("request")
27
+ if request is None:
28
+ # Try to find Request in args
29
+ from fastapi import Request
30
+ for arg in args:
31
+ if isinstance(arg, Request):
32
+ request = arg
33
+ break
34
+
35
+ if request is not None:
36
+ auth = request.headers.get("authorization", "")
37
+ if auth.startswith("L402 "):
38
+ token = auth[5:]
39
+ if verify_token(token):
40
+ macaroon, preimage = token.rsplit(":", 1)
41
+ if check_and_mark_preimage(preimage):
42
+ return await func(*args, **kwargs)
43
+ else:
44
+ from fastapi.responses import JSONResponse
45
+ return JSONResponse({"error": "Token already used"}, status_code=401)
46
+
47
+ invoice = await lightning.create_invoice(price_sats)
48
+ from fastapi.responses import JSONResponse
49
+ return JSONResponse(
50
+ {
51
+ "error": "Payment Required",
52
+ "price_sats": price_sats,
53
+ "invoice": invoice.payment_request,
54
+ "macaroon": invoice.macaroon,
55
+ },
56
+ status_code=402,
57
+ headers={
58
+ "WWW-Authenticate": f'L402 macaroon="{invoice.macaroon}", invoice="{invoice.payment_request}"'
59
+ },
60
+ )
61
+
62
+ return await func(*args, **kwargs)
63
+
64
+ @functools.wraps(func)
65
+ def flask_wrapper(*args, **kwargs):
66
+ from flask import request, jsonify, make_response
67
+ import asyncio
68
+
69
+ auth = request.headers.get("Authorization", "")
70
+ if auth.startswith("L402 "):
71
+ token = auth[5:]
72
+ if verify_token(token):
73
+ macaroon, preimage = token.rsplit(":", 1)
74
+ if check_and_mark_preimage(preimage):
75
+ return func(*args, **kwargs)
76
+ else:
77
+ return jsonify({"error": "Token already used"}), 401
78
+
79
+ invoice = asyncio.run(lightning.create_invoice(price_sats))
80
+ resp = make_response(
81
+ jsonify({
82
+ "error": "Payment Required",
83
+ "price_sats": price_sats,
84
+ "invoice": invoice.payment_request,
85
+ "macaroon": invoice.macaroon,
86
+ }),
87
+ 402,
88
+ )
89
+ resp.headers["WWW-Authenticate"] = (
90
+ f'L402 macaroon="{invoice.macaroon}", invoice="{invoice.payment_request}"'
91
+ )
92
+ return resp
93
+
94
+ import asyncio
95
+ import inspect
96
+ if inspect.iscoroutinefunction(func):
97
+ return fastapi_wrapper
98
+ return flask_wrapper
99
+
100
+ return decorator
@@ -0,0 +1,5 @@
1
+ from .blink import BlinkProvider
2
+ from .opennode import OpenNodeProvider
3
+ from .lnbits import LNbitsProvider
4
+
5
+ __all__ = ["BlinkProvider", "OpenNodeProvider", "LNbitsProvider"]
@@ -0,0 +1,69 @@
1
+ import time
2
+ import base64
3
+ import json
4
+ import httpx
5
+ from ..types import Invoice
6
+
7
+
8
+ class BlinkProvider:
9
+ """
10
+ Blink Lightning provider — free, no node needed.
11
+ Get API key at dashboard.blink.sv
12
+ """
13
+
14
+ def __init__(self, api_key: str, wallet_id: str):
15
+ self.api_key = api_key
16
+ self.wallet_id = wallet_id
17
+
18
+ async def create_invoice(self, amount_sats: int) -> Invoice:
19
+ query = """
20
+ mutation CreateInvoice($input: LnInvoiceCreateInput!) {
21
+ lnInvoiceCreate(input: $input) {
22
+ invoice { paymentRequest paymentHash }
23
+ errors { message }
24
+ }
25
+ }
26
+ """
27
+ async with httpx.AsyncClient() as client:
28
+ res = await client.post(
29
+ "https://api.blink.sv/graphql",
30
+ headers={"X-API-KEY": self.api_key},
31
+ json={"query": query, "variables": {
32
+ "input": {"walletId": self.wallet_id, "amount": amount_sats, "memo": "L402 API access"}
33
+ }},
34
+ )
35
+ res.raise_for_status()
36
+ data = res.json()["data"]["lnInvoiceCreate"]
37
+
38
+ if data.get("errors"):
39
+ raise Exception(f"Blink error: {data['errors'][0]['message']}")
40
+
41
+ inv = data["invoice"]
42
+ macaroon = base64.b64encode(json.dumps({
43
+ "hash": inv["paymentHash"],
44
+ "exp": int(time.time()) + 3600,
45
+ }).encode()).decode()
46
+
47
+ return Invoice(
48
+ payment_request=inv["paymentRequest"],
49
+ payment_hash=inv["paymentHash"],
50
+ macaroon=macaroon,
51
+ amount_sats=amount_sats,
52
+ expires_at=int(time.time()) + 3600,
53
+ )
54
+
55
+ async def check_payment(self, payment_hash: str) -> bool:
56
+ query = """
57
+ query CheckInvoice($paymentHash: PaymentHash!) {
58
+ lnInvoice(paymentHash: $paymentHash) { status }
59
+ }
60
+ """
61
+ async with httpx.AsyncClient() as client:
62
+ res = await client.post(
63
+ "https://api.blink.sv/graphql",
64
+ headers={"X-API-KEY": self.api_key},
65
+ json={"query": query, "variables": {"paymentHash": payment_hash}},
66
+ )
67
+ if not res.is_success:
68
+ return False
69
+ return res.json().get("data", {}).get("lnInvoice", {}).get("status") == "PAID"
@@ -0,0 +1,26 @@
1
+ import time, base64, json
2
+ import httpx
3
+ from ..types import Invoice
4
+
5
+
6
+ class LNbitsProvider:
7
+ def __init__(self, api_key: str, base_url: str = "https://legend.lnbits.com"):
8
+ self.api_key = api_key
9
+ self.base_url = base_url.rstrip("/")
10
+
11
+ async def create_invoice(self, amount_sats: int) -> Invoice:
12
+ async with httpx.AsyncClient() as client:
13
+ res = await client.post(
14
+ f"{self.base_url}/api/v1/payments",
15
+ headers={"X-Api-Key": self.api_key},
16
+ json={"out": False, "amount": amount_sats, "memo": "L402 API access"},
17
+ )
18
+ res.raise_for_status()
19
+ data = res.json()
20
+ macaroon = base64.b64encode(json.dumps({"hash": data["payment_hash"], "exp": int(time.time()) + 3600}).encode()).decode()
21
+ return Invoice(data["payment_request"], data["payment_hash"], macaroon, amount_sats, int(time.time()) + 3600)
22
+
23
+ async def check_payment(self, payment_hash: str) -> bool:
24
+ async with httpx.AsyncClient() as client:
25
+ res = await client.get(f"{self.base_url}/api/v1/payments/{payment_hash}", headers={"X-Api-Key": self.api_key})
26
+ return res.is_success and res.json().get("paid", False)
@@ -0,0 +1,26 @@
1
+ import time, base64, json
2
+ import httpx
3
+ from ..types import Invoice
4
+
5
+
6
+ class OpenNodeProvider:
7
+ def __init__(self, api_key: str, test_mode: bool = False):
8
+ self.api_key = api_key
9
+ self.base_url = "https://dev-api.opennode.com" if test_mode else "https://api.opennode.com"
10
+
11
+ async def create_invoice(self, amount_sats: int) -> Invoice:
12
+ async with httpx.AsyncClient() as client:
13
+ res = await client.post(
14
+ f"{self.base_url}/v1/charges",
15
+ headers={"Authorization": self.api_key},
16
+ json={"amount": amount_sats, "description": "L402 API access", "currency": "SATS"},
17
+ )
18
+ res.raise_for_status()
19
+ data = res.json()["data"]
20
+ macaroon = base64.b64encode(json.dumps({"hash": data["id"], "exp": int(time.time()) + 3600}).encode()).decode()
21
+ return Invoice(data["lightning_invoice"]["payreq"], data["id"], macaroon, amount_sats, int(time.time()) + 3600)
22
+
23
+ async def check_payment(self, charge_id: str) -> bool:
24
+ async with httpx.AsyncClient() as client:
25
+ res = await client.get(f"{self.base_url}/v1/charge/{charge_id}", headers={"Authorization": self.api_key})
26
+ return res.is_success and res.json().get("data", {}).get("status") == "paid"
@@ -0,0 +1,11 @@
1
+ import hashlib
2
+
3
+ _used_preimages: set[str] = set()
4
+
5
+
6
+ def check_and_mark_preimage(preimage: str) -> bool:
7
+ key = hashlib.sha256(preimage.encode()).hexdigest()
8
+ if key in _used_preimages:
9
+ return False
10
+ _used_preimages.add(key)
11
+ return True
@@ -0,0 +1,16 @@
1
+ from dataclasses import dataclass
2
+ from typing import Protocol
3
+
4
+
5
+ @dataclass
6
+ class Invoice:
7
+ payment_request: str
8
+ payment_hash: str
9
+ macaroon: str
10
+ amount_sats: int
11
+ expires_at: int
12
+
13
+
14
+ class LightningProvider(Protocol):
15
+ async def create_invoice(self, amount_sats: int) -> Invoice: ...
16
+ async def check_payment(self, payment_hash: str) -> bool: ...
@@ -0,0 +1,33 @@
1
+ import hashlib
2
+ import base64
3
+ import json
4
+ import time
5
+ import re
6
+
7
+
8
+ def parse_token(token: str) -> tuple[str, str]:
9
+ idx = token.rfind(":")
10
+ if idx == -1:
11
+ raise ValueError("Invalid L402 token format")
12
+ return token[:idx], token[idx + 1:]
13
+
14
+
15
+ def verify_token(token: str) -> bool:
16
+ try:
17
+ macaroon, preimage = parse_token(token)
18
+
19
+ if not macaroon or not re.fullmatch(r"[0-9a-fA-F]{64}", preimage):
20
+ return False
21
+
22
+ payload = json.loads(base64.b64decode(macaroon).decode())
23
+ if not payload.get("hash") or not payload.get("exp"):
24
+ return False
25
+
26
+ if int(time.time()) > payload["exp"]:
27
+ return False
28
+
29
+ digest = hashlib.sha256(bytes.fromhex(preimage)).hexdigest()
30
+ return digest == payload["hash"]
31
+
32
+ except Exception:
33
+ return False
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "l402kit"
7
+ version = "0.1.0"
8
+ description = "Lightning Network L402 payment middleware for FastAPI and Flask"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.11"
12
+ dependencies = [
13
+ "httpx>=0.27",
14
+ ]
15
+
16
+ [project.optional-dependencies]
17
+ fastapi = ["fastapi>=0.110"]
18
+ flask = ["flask>=3.0"]
19
+
20
+ [project.urls]
21
+ Homepage = "https://github.com/shinydapps/l402-kit"