payclaw 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.
- payclaw-0.1.0/PKG-INFO +201 -0
- payclaw-0.1.0/README.md +187 -0
- payclaw-0.1.0/payclaw/__init__.py +23 -0
- payclaw-0.1.0/payclaw/config.py +55 -0
- payclaw-0.1.0/payclaw/middleware.py +195 -0
- payclaw-0.1.0/payclaw/nonce_cache.py +43 -0
- payclaw-0.1.0/payclaw/verify.py +92 -0
- payclaw-0.1.0/payclaw.egg-info/PKG-INFO +201 -0
- payclaw-0.1.0/payclaw.egg-info/SOURCES.txt +16 -0
- payclaw-0.1.0/payclaw.egg-info/dependency_links.txt +1 -0
- payclaw-0.1.0/payclaw.egg-info/requires.txt +7 -0
- payclaw-0.1.0/payclaw.egg-info/top_level.txt +1 -0
- payclaw-0.1.0/pyproject.toml +23 -0
- payclaw-0.1.0/setup.cfg +4 -0
- payclaw-0.1.0/tests/test_config.py +53 -0
- payclaw-0.1.0/tests/test_middleware.py +84 -0
- payclaw-0.1.0/tests/test_nonce_cache.py +65 -0
- payclaw-0.1.0/tests/test_verify.py +116 -0
payclaw-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: payclaw
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Drop-in x402 payment middleware for MCP servers — charge AI agents per tool call with USDC
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/TeaBay/payclaw
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: requests>=2.28
|
|
10
|
+
Provides-Extra: fastapi
|
|
11
|
+
Requires-Dist: fastapi>=0.100; extra == "fastapi"
|
|
12
|
+
Provides-Extra: flask
|
|
13
|
+
Requires-Dist: flask>=2.3; extra == "flask"
|
|
14
|
+
|
|
15
|
+
# payclaw
|
|
16
|
+
|
|
17
|
+
Drop-in x402 payment middleware for MCP servers. Charge AI agents per tool call using USDC on Base chain — 10 lines of code, no payment processor, no KYC.
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
pip install payclaw
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## How it works
|
|
24
|
+
|
|
25
|
+
1. Agent calls your tool endpoint
|
|
26
|
+
2. No valid payment → server returns **HTTP 402** with price and wallet address
|
|
27
|
+
3. Agent pays USDC on Base chain, gets tx hash
|
|
28
|
+
4. Agent retries with `X-Payment: <tx_hash>` header
|
|
29
|
+
5. payclaw verifies on-chain → executes your tool
|
|
30
|
+
|
|
31
|
+
Money flows directly: **agent wallet → your wallet**. payclaw never holds funds.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## FastAPI
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from fastapi import FastAPI, Request
|
|
39
|
+
from payclaw import require_payment, PayclawConfig
|
|
40
|
+
|
|
41
|
+
app = FastAPI()
|
|
42
|
+
|
|
43
|
+
config = PayclawConfig(
|
|
44
|
+
price_usdc=0.001,
|
|
45
|
+
wallet_address="0xYourWalletAddress",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
@app.post("/search")
|
|
49
|
+
@require_payment(config)
|
|
50
|
+
async def search(request: Request, q: str):
|
|
51
|
+
return {"results": ["result1", "result2"]}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Flask
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from flask import Flask, jsonify
|
|
58
|
+
from payclaw import require_payment, PayclawConfig
|
|
59
|
+
|
|
60
|
+
app = Flask(__name__)
|
|
61
|
+
|
|
62
|
+
config = PayclawConfig(
|
|
63
|
+
price_usdc=0.001,
|
|
64
|
+
wallet_address="0xYourWalletAddress",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
@app.route("/search", methods=["POST"])
|
|
68
|
+
@require_payment(config)
|
|
69
|
+
def search():
|
|
70
|
+
return jsonify({"results": ["result1", "result2"]})
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Custom framework
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from payclaw import PayclawMiddleware, PayclawConfig
|
|
77
|
+
|
|
78
|
+
config = PayclawConfig(price_usdc=0.001, wallet_address="0xYourWallet")
|
|
79
|
+
middleware = PayclawMiddleware(config)
|
|
80
|
+
|
|
81
|
+
# In your request handler:
|
|
82
|
+
allowed, reason = middleware.check(dict(request.headers))
|
|
83
|
+
if not allowed:
|
|
84
|
+
status, body = middleware.payment_required(reason)
|
|
85
|
+
# return 402 response with body
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## 402 Response format
|
|
91
|
+
|
|
92
|
+
```json
|
|
93
|
+
{
|
|
94
|
+
"x402": true,
|
|
95
|
+
"price": "0.001",
|
|
96
|
+
"currency": "USDC",
|
|
97
|
+
"network": "base-sepolia",
|
|
98
|
+
"recipient": "0xYourWallet",
|
|
99
|
+
"chain_id": 84532,
|
|
100
|
+
"reason": "missing X-Payment header"
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
When rate limit is exceeded, the response is HTTP **429** with the same body format and `"reason": "rate limit exceeded"`.
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## Base Mainnet
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from payclaw import mainnet_config, require_payment
|
|
112
|
+
|
|
113
|
+
config = mainnet_config(
|
|
114
|
+
price_usdc=0.001,
|
|
115
|
+
wallet_address="0xYourWallet",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
@app.post("/tool")
|
|
119
|
+
@require_payment(config)
|
|
120
|
+
async def my_tool(request: Request):
|
|
121
|
+
return {"result": "..."}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## Config options
|
|
127
|
+
|
|
128
|
+
| Parameter | Default | Description |
|
|
129
|
+
|-----------|---------|-------------|
|
|
130
|
+
| `price_usdc` | required | Price per call in USDC |
|
|
131
|
+
| `wallet_address` | required | Your wallet (0x...) |
|
|
132
|
+
| `network` | `base-sepolia` | Network name |
|
|
133
|
+
| `chain_id` | `84532` | Chain ID |
|
|
134
|
+
| `usdc_address` | Base Sepolia USDC | USDC contract address |
|
|
135
|
+
| `rpc_url` | `https://sepolia.base.org` | JSON-RPC endpoint |
|
|
136
|
+
| `freshness_seconds` | `300` | Max tx age in seconds |
|
|
137
|
+
| `nonce_cache_ttl` | `600` | Nonce cache TTL in seconds |
|
|
138
|
+
| `nonce_db_path` | `.payclaw_nonces.db` | SQLite file for replay protection |
|
|
139
|
+
| `rate_limit_requests` | `10` | Max requests per IP per window (0 = disabled) |
|
|
140
|
+
| `rate_limit_window_seconds` | `60` | Rate limit window in seconds |
|
|
141
|
+
| `trust_proxy` | `False` | Trust X-Forwarded-For for per-IP rate limiting. Set `True` only when behind a trusted reverse proxy. When `False`, all traffic shares one rate limit bucket. |
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Getting testnet USDC
|
|
146
|
+
|
|
147
|
+
Get free testnet USDC from the [Circle faucet](https://faucet.circle.com) — select **Base Sepolia** and paste your wallet address.
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## Containerized deployments
|
|
152
|
+
|
|
153
|
+
The nonce cache is a SQLite file (default: `.payclaw_nonces.db`). In Docker or serverless environments where the filesystem is ephemeral, **mount a persistent volume** or point `nonce_db_path` to a mounted path:
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
config = PayclawConfig(
|
|
157
|
+
price_usdc=0.001,
|
|
158
|
+
wallet_address="0xYourWallet",
|
|
159
|
+
nonce_db_path="/data/payclaw_nonces.db", # mounted volume
|
|
160
|
+
)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Without persistence, a container restart clears the nonce cache and allows replay attacks within the `freshness_seconds` window.
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## Async frameworks (FastAPI)
|
|
168
|
+
|
|
169
|
+
The `verify_payment` function uses synchronous HTTP (`requests`). In an async FastAPI app, this blocks the event loop during RPC calls. For high-throughput deployments, wrap with `asyncio.to_thread`:
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
import asyncio
|
|
173
|
+
from payclaw import PayclawMiddleware, PayclawConfig
|
|
174
|
+
|
|
175
|
+
middleware = PayclawMiddleware(config)
|
|
176
|
+
|
|
177
|
+
@app.post("/tool")
|
|
178
|
+
async def my_tool(request: Request):
|
|
179
|
+
headers = dict(request.headers)
|
|
180
|
+
allowed, reason = await asyncio.to_thread(middleware.check, headers)
|
|
181
|
+
if not allowed:
|
|
182
|
+
_, body = middleware.payment_required(reason)
|
|
183
|
+
return JSONResponse(status_code=402, content=body)
|
|
184
|
+
return {"result": "..."}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## Security
|
|
190
|
+
|
|
191
|
+
- **Replay protection**: SQLite nonce cache survives process restarts. Atomic INSERT OR IGNORE prevents race conditions.
|
|
192
|
+
- **ERC-20 verification**: Reads Transfer event logs from `eth_getTransactionReceipt` — not `tx.value` (which is always 0 for USDC).
|
|
193
|
+
- **Integer math**: USDC amounts compared as integer units (1 USDC = 1,000,000 units). No floating point.
|
|
194
|
+
- **Block timestamp**: Uses on-chain block timestamp for freshness check, not local clock.
|
|
195
|
+
- **Address matching**: Case-insensitive comparison (EIP-55 safe).
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## Legal
|
|
200
|
+
|
|
201
|
+
MIT License. payclaw is infrastructure software. Compliance with sanctions (OFAC) and applicable regulations is the responsibility of the deploying party.
|
payclaw-0.1.0/README.md
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# payclaw
|
|
2
|
+
|
|
3
|
+
Drop-in x402 payment middleware for MCP servers. Charge AI agents per tool call using USDC on Base chain — 10 lines of code, no payment processor, no KYC.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
pip install payclaw
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## How it works
|
|
10
|
+
|
|
11
|
+
1. Agent calls your tool endpoint
|
|
12
|
+
2. No valid payment → server returns **HTTP 402** with price and wallet address
|
|
13
|
+
3. Agent pays USDC on Base chain, gets tx hash
|
|
14
|
+
4. Agent retries with `X-Payment: <tx_hash>` header
|
|
15
|
+
5. payclaw verifies on-chain → executes your tool
|
|
16
|
+
|
|
17
|
+
Money flows directly: **agent wallet → your wallet**. payclaw never holds funds.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## FastAPI
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from fastapi import FastAPI, Request
|
|
25
|
+
from payclaw import require_payment, PayclawConfig
|
|
26
|
+
|
|
27
|
+
app = FastAPI()
|
|
28
|
+
|
|
29
|
+
config = PayclawConfig(
|
|
30
|
+
price_usdc=0.001,
|
|
31
|
+
wallet_address="0xYourWalletAddress",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
@app.post("/search")
|
|
35
|
+
@require_payment(config)
|
|
36
|
+
async def search(request: Request, q: str):
|
|
37
|
+
return {"results": ["result1", "result2"]}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Flask
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from flask import Flask, jsonify
|
|
44
|
+
from payclaw import require_payment, PayclawConfig
|
|
45
|
+
|
|
46
|
+
app = Flask(__name__)
|
|
47
|
+
|
|
48
|
+
config = PayclawConfig(
|
|
49
|
+
price_usdc=0.001,
|
|
50
|
+
wallet_address="0xYourWalletAddress",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
@app.route("/search", methods=["POST"])
|
|
54
|
+
@require_payment(config)
|
|
55
|
+
def search():
|
|
56
|
+
return jsonify({"results": ["result1", "result2"]})
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Custom framework
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from payclaw import PayclawMiddleware, PayclawConfig
|
|
63
|
+
|
|
64
|
+
config = PayclawConfig(price_usdc=0.001, wallet_address="0xYourWallet")
|
|
65
|
+
middleware = PayclawMiddleware(config)
|
|
66
|
+
|
|
67
|
+
# In your request handler:
|
|
68
|
+
allowed, reason = middleware.check(dict(request.headers))
|
|
69
|
+
if not allowed:
|
|
70
|
+
status, body = middleware.payment_required(reason)
|
|
71
|
+
# return 402 response with body
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## 402 Response format
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
{
|
|
80
|
+
"x402": true,
|
|
81
|
+
"price": "0.001",
|
|
82
|
+
"currency": "USDC",
|
|
83
|
+
"network": "base-sepolia",
|
|
84
|
+
"recipient": "0xYourWallet",
|
|
85
|
+
"chain_id": 84532,
|
|
86
|
+
"reason": "missing X-Payment header"
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
When rate limit is exceeded, the response is HTTP **429** with the same body format and `"reason": "rate limit exceeded"`.
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Base Mainnet
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from payclaw import mainnet_config, require_payment
|
|
98
|
+
|
|
99
|
+
config = mainnet_config(
|
|
100
|
+
price_usdc=0.001,
|
|
101
|
+
wallet_address="0xYourWallet",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
@app.post("/tool")
|
|
105
|
+
@require_payment(config)
|
|
106
|
+
async def my_tool(request: Request):
|
|
107
|
+
return {"result": "..."}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Config options
|
|
113
|
+
|
|
114
|
+
| Parameter | Default | Description |
|
|
115
|
+
|-----------|---------|-------------|
|
|
116
|
+
| `price_usdc` | required | Price per call in USDC |
|
|
117
|
+
| `wallet_address` | required | Your wallet (0x...) |
|
|
118
|
+
| `network` | `base-sepolia` | Network name |
|
|
119
|
+
| `chain_id` | `84532` | Chain ID |
|
|
120
|
+
| `usdc_address` | Base Sepolia USDC | USDC contract address |
|
|
121
|
+
| `rpc_url` | `https://sepolia.base.org` | JSON-RPC endpoint |
|
|
122
|
+
| `freshness_seconds` | `300` | Max tx age in seconds |
|
|
123
|
+
| `nonce_cache_ttl` | `600` | Nonce cache TTL in seconds |
|
|
124
|
+
| `nonce_db_path` | `.payclaw_nonces.db` | SQLite file for replay protection |
|
|
125
|
+
| `rate_limit_requests` | `10` | Max requests per IP per window (0 = disabled) |
|
|
126
|
+
| `rate_limit_window_seconds` | `60` | Rate limit window in seconds |
|
|
127
|
+
| `trust_proxy` | `False` | Trust X-Forwarded-For for per-IP rate limiting. Set `True` only when behind a trusted reverse proxy. When `False`, all traffic shares one rate limit bucket. |
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Getting testnet USDC
|
|
132
|
+
|
|
133
|
+
Get free testnet USDC from the [Circle faucet](https://faucet.circle.com) — select **Base Sepolia** and paste your wallet address.
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## Containerized deployments
|
|
138
|
+
|
|
139
|
+
The nonce cache is a SQLite file (default: `.payclaw_nonces.db`). In Docker or serverless environments where the filesystem is ephemeral, **mount a persistent volume** or point `nonce_db_path` to a mounted path:
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
config = PayclawConfig(
|
|
143
|
+
price_usdc=0.001,
|
|
144
|
+
wallet_address="0xYourWallet",
|
|
145
|
+
nonce_db_path="/data/payclaw_nonces.db", # mounted volume
|
|
146
|
+
)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Without persistence, a container restart clears the nonce cache and allows replay attacks within the `freshness_seconds` window.
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## Async frameworks (FastAPI)
|
|
154
|
+
|
|
155
|
+
The `verify_payment` function uses synchronous HTTP (`requests`). In an async FastAPI app, this blocks the event loop during RPC calls. For high-throughput deployments, wrap with `asyncio.to_thread`:
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
import asyncio
|
|
159
|
+
from payclaw import PayclawMiddleware, PayclawConfig
|
|
160
|
+
|
|
161
|
+
middleware = PayclawMiddleware(config)
|
|
162
|
+
|
|
163
|
+
@app.post("/tool")
|
|
164
|
+
async def my_tool(request: Request):
|
|
165
|
+
headers = dict(request.headers)
|
|
166
|
+
allowed, reason = await asyncio.to_thread(middleware.check, headers)
|
|
167
|
+
if not allowed:
|
|
168
|
+
_, body = middleware.payment_required(reason)
|
|
169
|
+
return JSONResponse(status_code=402, content=body)
|
|
170
|
+
return {"result": "..."}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Security
|
|
176
|
+
|
|
177
|
+
- **Replay protection**: SQLite nonce cache survives process restarts. Atomic INSERT OR IGNORE prevents race conditions.
|
|
178
|
+
- **ERC-20 verification**: Reads Transfer event logs from `eth_getTransactionReceipt` — not `tx.value` (which is always 0 for USDC).
|
|
179
|
+
- **Integer math**: USDC amounts compared as integer units (1 USDC = 1,000,000 units). No floating point.
|
|
180
|
+
- **Block timestamp**: Uses on-chain block timestamp for freshness check, not local clock.
|
|
181
|
+
- **Address matching**: Case-insensitive comparison (EIP-55 safe).
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## Legal
|
|
186
|
+
|
|
187
|
+
MIT License. payclaw is infrastructure software. Compliance with sanctions (OFAC) and applicable regulations is the responsibility of the deploying party.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
payclaw - Drop-in payment middleware for MCP servers using x402 protocol.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
from payclaw import require_payment, PayclawConfig
|
|
6
|
+
|
|
7
|
+
config = PayclawConfig(
|
|
8
|
+
price_usdc=0.001,
|
|
9
|
+
wallet_address="0xYourWalletAddress",
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
@require_payment(config)
|
|
13
|
+
async def my_mcp_tool(request: Request):
|
|
14
|
+
return {"result": "tool output"}
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from payclaw.config import PayclawConfig, mainnet_config
|
|
18
|
+
from payclaw.middleware import PayclawMiddleware, require_payment
|
|
19
|
+
from payclaw.nonce_cache import NonceCache
|
|
20
|
+
from payclaw.verify import verify_payment
|
|
21
|
+
|
|
22
|
+
__all__ = ["require_payment", "PayclawMiddleware", "PayclawConfig", "mainnet_config", "NonceCache", "verify_payment"]
|
|
23
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from decimal import Decimal
|
|
3
|
+
|
|
4
|
+
USDC_BASE_SEPOLIA = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"
|
|
5
|
+
RPC_BASE_SEPOLIA = "https://sepolia.base.org"
|
|
6
|
+
|
|
7
|
+
USDC_BASE_MAINNET = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
|
|
8
|
+
RPC_BASE_MAINNET = "https://mainnet.base.org"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class PayclawConfig:
|
|
13
|
+
price_usdc: float
|
|
14
|
+
wallet_address: str
|
|
15
|
+
network: str = "base-sepolia"
|
|
16
|
+
chain_id: int = 84532
|
|
17
|
+
usdc_address: str = USDC_BASE_SEPOLIA
|
|
18
|
+
rpc_url: str = RPC_BASE_SEPOLIA
|
|
19
|
+
freshness_seconds: int = 300
|
|
20
|
+
nonce_cache_ttl: int = 600
|
|
21
|
+
nonce_db_path: str = ".payclaw_nonces.db"
|
|
22
|
+
rate_limit_requests: int = 10
|
|
23
|
+
rate_limit_window_seconds: int = 60
|
|
24
|
+
trust_proxy: bool = False # set True only when behind a trusted reverse proxy
|
|
25
|
+
|
|
26
|
+
def __post_init__(self):
|
|
27
|
+
if (
|
|
28
|
+
not isinstance(self.wallet_address, str)
|
|
29
|
+
or not self.wallet_address.startswith("0x")
|
|
30
|
+
or len(self.wallet_address) != 42
|
|
31
|
+
):
|
|
32
|
+
raise ValueError(f"Invalid wallet_address: {self.wallet_address!r}")
|
|
33
|
+
if self.price_usdc <= 0:
|
|
34
|
+
raise ValueError("price_usdc must be > 0")
|
|
35
|
+
if self.nonce_cache_ttl < self.freshness_seconds:
|
|
36
|
+
raise ValueError("nonce_cache_ttl must be >= freshness_seconds")
|
|
37
|
+
if not self.rpc_url.startswith("https://"):
|
|
38
|
+
raise ValueError("rpc_url must use HTTPS")
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def price_units(self) -> int:
|
|
42
|
+
return int(Decimal(str(self.price_usdc)) * 1_000_000)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def mainnet_config(price_usdc: float, wallet_address: str, **kwargs) -> "PayclawConfig":
|
|
46
|
+
"""Convenience factory for Base mainnet."""
|
|
47
|
+
return PayclawConfig(
|
|
48
|
+
price_usdc=price_usdc,
|
|
49
|
+
wallet_address=wallet_address,
|
|
50
|
+
network="base",
|
|
51
|
+
chain_id=8453,
|
|
52
|
+
usdc_address=USDC_BASE_MAINNET,
|
|
53
|
+
rpc_url=RPC_BASE_MAINNET,
|
|
54
|
+
**kwargs,
|
|
55
|
+
)
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import functools
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
|
|
7
|
+
from payclaw.config import PayclawConfig
|
|
8
|
+
from payclaw.nonce_cache import NonceCache
|
|
9
|
+
from payclaw.verify import verify_payment
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_RATE_LIMIT_REASON = "rate limit exceeded"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _RateLimiter:
|
|
16
|
+
"""Per-key sliding window rate limiter."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, max_requests: int, window_seconds: int):
|
|
19
|
+
self._max = max_requests
|
|
20
|
+
self._window = window_seconds
|
|
21
|
+
self._lock = threading.Lock()
|
|
22
|
+
self._log: dict[str, list[float]] = defaultdict(list)
|
|
23
|
+
self._calls = 0
|
|
24
|
+
|
|
25
|
+
def is_allowed(self, key: str) -> bool:
|
|
26
|
+
now = time.monotonic()
|
|
27
|
+
cutoff = now - self._window
|
|
28
|
+
with self._lock:
|
|
29
|
+
self._calls += 1
|
|
30
|
+
if self._calls % 500 == 0:
|
|
31
|
+
stale = [k for k, ts in self._log.items() if not any(t > cutoff for t in ts)]
|
|
32
|
+
for k in stale:
|
|
33
|
+
del self._log[k]
|
|
34
|
+
fresh = [t for t in self._log.get(key, []) if t > cutoff]
|
|
35
|
+
if len(fresh) >= self._max:
|
|
36
|
+
self._log[key] = fresh
|
|
37
|
+
return False
|
|
38
|
+
fresh.append(now)
|
|
39
|
+
self._log[key] = fresh
|
|
40
|
+
return True
|
|
41
|
+
|
|
42
|
+
def evict(self):
|
|
43
|
+
"""Remove keys with no recent requests. Call periodically if needed."""
|
|
44
|
+
now = time.monotonic()
|
|
45
|
+
cutoff = now - self._window
|
|
46
|
+
with self._lock:
|
|
47
|
+
stale = [k for k, ts in self._log.items() if not any(t > cutoff for t in ts)]
|
|
48
|
+
for k in stale:
|
|
49
|
+
del self._log[k]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _build_402(config: PayclawConfig, reason: str = "") -> dict:
|
|
53
|
+
body = {
|
|
54
|
+
"x402": True,
|
|
55
|
+
"price": str(config.price_usdc),
|
|
56
|
+
"currency": "USDC",
|
|
57
|
+
"network": config.network,
|
|
58
|
+
"recipient": config.wallet_address,
|
|
59
|
+
"chain_id": config.chain_id,
|
|
60
|
+
}
|
|
61
|
+
if reason:
|
|
62
|
+
body["reason"] = reason
|
|
63
|
+
return body
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class PayclawMiddleware:
|
|
67
|
+
"""Framework-agnostic payment gate. Use .check(headers) directly for custom integrations."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, config: PayclawConfig):
|
|
70
|
+
self.config = config
|
|
71
|
+
self._nonce_cache = NonceCache(config.nonce_db_path, config.nonce_cache_ttl)
|
|
72
|
+
self._rate_limiter = (
|
|
73
|
+
_RateLimiter(config.rate_limit_requests, config.rate_limit_window_seconds)
|
|
74
|
+
if config.rate_limit_requests > 0
|
|
75
|
+
else None
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def check(self, headers: dict) -> tuple[bool, str]:
|
|
79
|
+
"""
|
|
80
|
+
Returns (allowed, reason).
|
|
81
|
+
headers: any dict-like with HTTP headers (case-insensitive lookup attempted).
|
|
82
|
+
"""
|
|
83
|
+
if self._rate_limiter:
|
|
84
|
+
ip = self._client_ip(headers)
|
|
85
|
+
if not self._rate_limiter.is_allowed(ip):
|
|
86
|
+
return False, _RATE_LIMIT_REASON
|
|
87
|
+
|
|
88
|
+
tx_hash = headers.get("X-Payment") or headers.get("x-payment")
|
|
89
|
+
if not tx_hash:
|
|
90
|
+
return False, "missing X-Payment header"
|
|
91
|
+
tx_hash = tx_hash.strip()
|
|
92
|
+
if not tx_hash.startswith("0x") or len(tx_hash) != 66:
|
|
93
|
+
return False, "invalid tx hash format (must be 0x + 64 hex chars)"
|
|
94
|
+
try:
|
|
95
|
+
int(tx_hash, 16)
|
|
96
|
+
except ValueError:
|
|
97
|
+
return False, "invalid tx hash format (non-hex characters)"
|
|
98
|
+
return verify_payment(tx_hash, self.config, self._nonce_cache)
|
|
99
|
+
|
|
100
|
+
def payment_required(self, reason: str = "") -> tuple[int, dict]:
|
|
101
|
+
return 402, _build_402(self.config, reason)
|
|
102
|
+
|
|
103
|
+
def response_for(self, reason: str) -> tuple[int, dict]:
|
|
104
|
+
if reason == _RATE_LIMIT_REASON:
|
|
105
|
+
return 429, {"error": "Too Many Requests", "reason": reason}
|
|
106
|
+
return self.payment_required(reason)
|
|
107
|
+
|
|
108
|
+
def _client_ip(self, headers: dict) -> str:
|
|
109
|
+
if self.config.trust_proxy:
|
|
110
|
+
forwarded = headers.get("X-Forwarded-For") or headers.get("x-forwarded-for", "")
|
|
111
|
+
if forwarded:
|
|
112
|
+
return forwarded.split(",")[0].strip()
|
|
113
|
+
return "unknown"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def require_payment(config: PayclawConfig):
|
|
117
|
+
"""
|
|
118
|
+
Decorator that gates any FastAPI or Flask handler behind x402 payment.
|
|
119
|
+
|
|
120
|
+
FastAPI: the decorated function must accept `request: Request` as a parameter.
|
|
121
|
+
Flask: uses flask.request context automatically.
|
|
122
|
+
|
|
123
|
+
For other frameworks, use PayclawMiddleware.check(headers) directly.
|
|
124
|
+
|
|
125
|
+
Example (FastAPI):
|
|
126
|
+
@app.post("/tool")
|
|
127
|
+
@require_payment(config)
|
|
128
|
+
async def my_tool(request: Request):
|
|
129
|
+
return {"result": "..."}
|
|
130
|
+
|
|
131
|
+
Example (Flask):
|
|
132
|
+
@app.route("/tool", methods=["POST"])
|
|
133
|
+
@require_payment(config)
|
|
134
|
+
def my_tool():
|
|
135
|
+
return jsonify({"result": "..."})
|
|
136
|
+
"""
|
|
137
|
+
middleware = PayclawMiddleware(config)
|
|
138
|
+
|
|
139
|
+
def decorator(func):
|
|
140
|
+
if asyncio.iscoroutinefunction(func):
|
|
141
|
+
@functools.wraps(func)
|
|
142
|
+
async def async_wrapper(*args, **kwargs):
|
|
143
|
+
headers = _extract_headers(args, kwargs)
|
|
144
|
+
allowed, reason = middleware.check(headers)
|
|
145
|
+
if not allowed:
|
|
146
|
+
status, body = middleware.response_for(reason)
|
|
147
|
+
return _fastapi_response(status, body)
|
|
148
|
+
return await func(*args, **kwargs)
|
|
149
|
+
return async_wrapper
|
|
150
|
+
else:
|
|
151
|
+
@functools.wraps(func)
|
|
152
|
+
def sync_wrapper(*args, **kwargs):
|
|
153
|
+
headers = _flask_headers()
|
|
154
|
+
allowed, reason = middleware.check(headers)
|
|
155
|
+
if not allowed:
|
|
156
|
+
status, body = middleware.response_for(reason)
|
|
157
|
+
return _flask_response(status, body)
|
|
158
|
+
return func(*args, **kwargs)
|
|
159
|
+
return sync_wrapper
|
|
160
|
+
|
|
161
|
+
return decorator
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _extract_headers(args, kwargs) -> dict:
|
|
165
|
+
"""Pull headers from a FastAPI Request object in args or kwargs."""
|
|
166
|
+
request = kwargs.get("request")
|
|
167
|
+
if request is None:
|
|
168
|
+
for a in args:
|
|
169
|
+
if hasattr(a, "headers"):
|
|
170
|
+
request = a
|
|
171
|
+
break
|
|
172
|
+
if request and hasattr(request, "headers"):
|
|
173
|
+
return dict(request.headers)
|
|
174
|
+
return {}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _flask_headers() -> dict:
|
|
178
|
+
try:
|
|
179
|
+
from flask import request
|
|
180
|
+
return dict(request.headers)
|
|
181
|
+
except (ImportError, RuntimeError):
|
|
182
|
+
return {}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _fastapi_response(status: int, body: dict):
|
|
186
|
+
from fastapi.responses import JSONResponse
|
|
187
|
+
return JSONResponse(status_code=status, content=body)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _flask_response(status: int, body: dict):
|
|
191
|
+
try:
|
|
192
|
+
from flask import jsonify, make_response
|
|
193
|
+
return make_response(jsonify(body), status)
|
|
194
|
+
except ImportError:
|
|
195
|
+
return body, status
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
import threading
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class NonceCache:
|
|
7
|
+
def __init__(self, db_path: str, ttl: int = 600):
|
|
8
|
+
self._ttl = ttl
|
|
9
|
+
self._lock = threading.Lock()
|
|
10
|
+
self._conn = sqlite3.connect(db_path, check_same_thread=False)
|
|
11
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
12
|
+
self._conn.execute("""
|
|
13
|
+
CREATE TABLE IF NOT EXISTS used_nonces (
|
|
14
|
+
tx_hash TEXT PRIMARY KEY,
|
|
15
|
+
used_at INTEGER NOT NULL
|
|
16
|
+
)
|
|
17
|
+
""")
|
|
18
|
+
self._conn.commit()
|
|
19
|
+
|
|
20
|
+
def is_used(self, tx_hash: str) -> bool:
|
|
21
|
+
row = self._conn.execute(
|
|
22
|
+
"SELECT 1 FROM used_nonces WHERE tx_hash = ?", (tx_hash.lower(),)
|
|
23
|
+
).fetchone()
|
|
24
|
+
return row is not None
|
|
25
|
+
|
|
26
|
+
def mark_used(self, tx_hash: str) -> bool:
|
|
27
|
+
"""Atomically mark a tx hash as used. Returns True only on first use."""
|
|
28
|
+
tx_hash = tx_hash.lower()
|
|
29
|
+
now = int(time.time())
|
|
30
|
+
with self._lock:
|
|
31
|
+
self._cleanup(now)
|
|
32
|
+
cursor = self._conn.execute(
|
|
33
|
+
"INSERT OR IGNORE INTO used_nonces (tx_hash, used_at) VALUES (?, ?)",
|
|
34
|
+
(tx_hash, now),
|
|
35
|
+
)
|
|
36
|
+
self._conn.commit()
|
|
37
|
+
return cursor.rowcount == 1
|
|
38
|
+
|
|
39
|
+
def _cleanup(self, now: int):
|
|
40
|
+
self._conn.execute(
|
|
41
|
+
"DELETE FROM used_nonces WHERE used_at < ?",
|
|
42
|
+
(now - self._ttl,),
|
|
43
|
+
)
|