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.
- l402kit-0.1.0/.gitignore +30 -0
- l402kit-0.1.0/PKG-INFO +37 -0
- l402kit-0.1.0/README.md +23 -0
- l402kit-0.1.0/examples/fastapi_example.py +22 -0
- l402kit-0.1.0/l402kit/__init__.py +15 -0
- l402kit-0.1.0/l402kit/middleware.py +100 -0
- l402kit-0.1.0/l402kit/providers/__init__.py +5 -0
- l402kit-0.1.0/l402kit/providers/blink.py +69 -0
- l402kit-0.1.0/l402kit/providers/lnbits.py +26 -0
- l402kit-0.1.0/l402kit/providers/opennode.py +26 -0
- l402kit-0.1.0/l402kit/replay.py +11 -0
- l402kit-0.1.0/l402kit/types.py +16 -0
- l402kit-0.1.0/l402kit/verify.py +33 -0
- l402kit-0.1.0/pyproject.toml +21 -0
l402kit-0.1.0/.gitignore
ADDED
|
@@ -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
|
l402kit-0.1.0/README.md
ADDED
|
@@ -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,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,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"
|