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 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.
@@ -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
+ )