chipi-stack 2.0.0__py3-none-any.whl

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,207 @@
1
+ """x402 Payment Client for Starknet.
2
+
3
+ Wraps HTTP requests with automatic 402 payment handling.
4
+ When a server returns HTTP 402, the client parses the payment requirement,
5
+ validates against configured limits, signs the payment, and retries.
6
+ """
7
+
8
+ import asyncio
9
+ import json
10
+ import time
11
+ import uuid
12
+ from decimal import Decimal
13
+ from typing import Any, Optional
14
+
15
+ import httpx
16
+
17
+ from .constants import CONTRACT_ADDRESSES, TOKEN_DECIMALS
18
+ from .models.x402 import (
19
+ PaymentPayload,
20
+ PaymentPayloadData,
21
+ PaymentRequirement,
22
+ PaymentSignature,
23
+ X402ClientConfig,
24
+ )
25
+
26
+
27
+ class X402Client:
28
+ """x402 payment client for automatic HTTP 402 handling.
29
+
30
+ Args:
31
+ sdk: ChipiSDK instance for executing payments
32
+ config: Optional client configuration (max amount, allowed recipients, etc.)
33
+ """
34
+
35
+ def __init__(self, sdk: Any, config: Optional[X402ClientConfig] = None):
36
+ self.sdk = sdk
37
+ self.config = config or X402ClientConfig()
38
+
39
+ async def afetch(
40
+ self,
41
+ url: str,
42
+ bearer_token: Optional[str] = None,
43
+ wallet: Any = None,
44
+ encrypt_key: Optional[str] = None,
45
+ method: str = "GET",
46
+ **kwargs: Any,
47
+ ) -> httpx.Response:
48
+ """Async fetch with automatic x402 payment handling.
49
+
50
+ Args:
51
+ url: URL to fetch
52
+ bearer_token: Bearer token for Chipi API calls
53
+ wallet: Wallet data for SDK operations
54
+ encrypt_key: Encryption key for SDK operations
55
+ method: HTTP method (default: "GET"). Preserved on retry after payment.
56
+ **kwargs: Additional arguments passed to httpx (headers, params, etc.)
57
+
58
+ Returns:
59
+ httpx.Response from the server (after payment if needed)
60
+ """
61
+ async with httpx.AsyncClient() as client:
62
+ response = await client.request(method, url, **kwargs)
63
+
64
+ if response.status_code != 402:
65
+ return response
66
+
67
+ requirement = self._parse_requirement(response)
68
+ self._validate_requirement(requirement)
69
+ payment = self._build_payment(requirement)
70
+
71
+ # NOTE: Chipi SDK executes the transfer directly (client-side settlement)
72
+ # rather than producing a SNIP-12 signature for server-side settlement.
73
+ # Use verify-only mode on the server when using this client.
74
+ # TODO: Add local SNIP-12 signing when SDK exposes sign_typed_data().
75
+ amount_human = str(Decimal(requirement.max_amount_required) / Decimal(10 ** TOKEN_DECIMALS["USDC"]))
76
+ tx_hash = await self.sdk.atransfer(
77
+ params={
78
+ "wallet": wallet,
79
+ "encrypt_key": encrypt_key,
80
+ "token": "USDC",
81
+ "recipient": requirement.pay_to,
82
+ "amount": amount_human,
83
+ },
84
+ bearer_token=bearer_token,
85
+ )
86
+
87
+ # Populate fromAddress from wallet
88
+ if wallet and isinstance(wallet, dict) and "publicKey" in wallet:
89
+ payment.payload.from_address = wallet["publicKey"]
90
+ elif wallet and hasattr(wallet, "publicKey"):
91
+ payment.payload.from_address = wallet.publicKey
92
+
93
+ if payment.payload.from_address == "0x0":
94
+ raise ValueError("Unable to resolve payer address (fromAddress) from wallet")
95
+
96
+ payment.payload.signature = PaymentSignature(r=tx_hash, s="direct")
97
+
98
+ # Retry with payment header
99
+ headers = dict(kwargs.get("headers", {}))
100
+ headers[self.config.header_name] = payment.model_dump_json(by_alias=True)
101
+
102
+ return await client.request(method, url, headers=headers, **{k: v for k, v in kwargs.items() if k != "headers"})
103
+
104
+ def fetch(
105
+ self,
106
+ url: str,
107
+ bearer_token: Optional[str] = None,
108
+ wallet: Any = None,
109
+ encrypt_key: Optional[str] = None,
110
+ method: str = "GET",
111
+ **kwargs: Any,
112
+ ) -> httpx.Response:
113
+ """Sync fetch with automatic x402 payment handling.
114
+
115
+ Note: Uses asyncio.run() internally. Cannot be called from within an
116
+ existing event loop (e.g. FastAPI background tasks). Use afetch() directly
117
+ in async contexts.
118
+
119
+ Args:
120
+ url: URL to fetch
121
+ bearer_token: Bearer token for Chipi API calls
122
+ wallet: Wallet data for SDK operations
123
+ encrypt_key: Encryption key for SDK operations
124
+ method: HTTP method (default: "GET"). Preserved on retry after payment.
125
+ **kwargs: Additional arguments passed to httpx
126
+
127
+ Returns:
128
+ httpx.Response from the server (after payment if needed)
129
+ """
130
+ return asyncio.run(self.afetch(url, bearer_token=bearer_token, wallet=wallet, encrypt_key=encrypt_key, method=method, **kwargs))
131
+
132
+ def _parse_requirement(self, response: httpx.Response) -> PaymentRequirement:
133
+ """Parse PAYMENT-REQUIRED header from 402 response."""
134
+ header_value = response.headers.get("payment-required")
135
+
136
+ if not header_value:
137
+ raise ValueError(
138
+ "Server returned 402 but no PAYMENT-REQUIRED header. "
139
+ "The server may not support x402 or is misconfigured."
140
+ )
141
+
142
+ try:
143
+ data = json.loads(header_value)
144
+ except json.JSONDecodeError:
145
+ raise ValueError("Malformed PAYMENT-REQUIRED header: invalid JSON")
146
+
147
+ return PaymentRequirement(**data)
148
+
149
+ def _validate_requirement(self, requirement: PaymentRequirement) -> None:
150
+ """Validate payment requirement against client configuration."""
151
+ # Validate protocol constraints
152
+ if requirement.scheme != "exact":
153
+ raise ValueError(f"Unsupported payment scheme: {requirement.scheme}")
154
+ if requirement.network != "starknet-mainnet":
155
+ raise ValueError(f"Unsupported network: {requirement.network}")
156
+
157
+ # Validate amount
158
+ if self.config.max_payment_amount:
159
+ max_base_units = int(Decimal(self.config.max_payment_amount) * Decimal(10 ** TOKEN_DECIMALS["USDC"]))
160
+ required_amount = int(requirement.max_amount_required)
161
+
162
+ if required_amount > max_base_units:
163
+ required_human = Decimal(required_amount) / Decimal(10 ** TOKEN_DECIMALS["USDC"])
164
+ raise ValueError(
165
+ f"Payment amount {required_human} USDC exceeds maximum allowed "
166
+ f"{self.config.max_payment_amount} USDC"
167
+ )
168
+
169
+ # Validate recipient whitelist (normalize addresses for Starknet leading-zero equivalence)
170
+ if self.config.allowed_recipients:
171
+ def normalize(a: str) -> str:
172
+ return a.lower().lstrip("0x").lstrip("0")
173
+
174
+ normalized = normalize(requirement.pay_to)
175
+ if not any(normalize(addr) == normalized for addr in self.config.allowed_recipients):
176
+ raise ValueError(
177
+ f"Recipient {requirement.pay_to} is not in the allowed recipients list"
178
+ )
179
+
180
+ # Validate asset is USDC
181
+ normalized_asset = requirement.asset.lower().lstrip("0x").lstrip("0")
182
+ normalized_usdc = CONTRACT_ADDRESSES["USDC_MAINNET"].lower().lstrip("0x").lstrip("0")
183
+ if normalized_asset != normalized_usdc:
184
+ raise ValueError(
185
+ f"Unsupported asset: {requirement.asset}. "
186
+ f"Only USDC ({CONTRACT_ADDRESSES['USDC_MAINNET']}) is supported."
187
+ )
188
+
189
+ def _build_payment(self, requirement: PaymentRequirement) -> PaymentPayload:
190
+ """Build a PaymentPayload structure (signature filled in after execution)."""
191
+ nonce = str(uuid.uuid4())
192
+ valid_until = int(time.time()) + requirement.max_timeout_seconds
193
+
194
+ return PaymentPayload(
195
+ x402Version=1,
196
+ scheme="exact",
197
+ network="starknet-mainnet",
198
+ payload=PaymentPayloadData(
199
+ signature=PaymentSignature(r="0x0", s="0x0"), # Placeholder
200
+ fromAddress="0x0", # Filled in by SDK
201
+ toAddress=requirement.pay_to,
202
+ amount=requirement.max_amount_required,
203
+ asset=requirement.asset,
204
+ validUntil=valid_until,
205
+ nonce=nonce,
206
+ ),
207
+ )
@@ -0,0 +1,200 @@
1
+ """x402 Payment Facilitator for Starknet.
2
+
3
+ Implements the "facilitator" role in the x402 protocol:
4
+ - verify(): Validate payment signature, amount, asset, nonce replay
5
+ - settle(): Execute the USDC transfer via Chipi's paymaster
6
+ """
7
+
8
+ import asyncio
9
+ import time
10
+ from abc import ABC, abstractmethod
11
+ from typing import Any, Optional
12
+
13
+ from .constants import CONTRACT_ADDRESSES, TOKEN_DECIMALS
14
+ from .models.x402 import PaymentPayload, SettleResponse, VerifyResponse
15
+
16
+
17
+ class NonceStore(ABC):
18
+ """Pluggable nonce store for replay protection.
19
+
20
+ Developers can provide Redis, DB, or any persistent backend.
21
+ """
22
+
23
+ @abstractmethod
24
+ async def ahas(self, nonce: str) -> bool:
25
+ """Check if nonce has been used (non-consuming read)."""
26
+ ...
27
+
28
+ @abstractmethod
29
+ async def aconsume(self, nonce: str) -> bool:
30
+ """Atomically consume a nonce. Returns True if consumed (was new), False if already used.
31
+
32
+ Must be atomic to prevent concurrent replay attacks.
33
+ Maps to Redis SETNX, PostgreSQL INSERT ON CONFLICT DO NOTHING, etc.
34
+ """
35
+ ...
36
+
37
+ def has(self, nonce: str) -> bool:
38
+ """Sync check if nonce has been used."""
39
+ return asyncio.run(self.ahas(nonce))
40
+
41
+ def consume(self, nonce: str) -> bool:
42
+ """Sync atomically consume a nonce."""
43
+ return asyncio.run(self.aconsume(nonce))
44
+
45
+
46
+ class InMemoryNonceStore(NonceStore):
47
+ """Default in-memory store. Resets on process restart."""
48
+
49
+ def __init__(self) -> None:
50
+ self._used: set[str] = set()
51
+ self._lock = asyncio.Lock()
52
+
53
+ async def ahas(self, nonce: str) -> bool:
54
+ async with self._lock:
55
+ return nonce in self._used
56
+
57
+ async def aconsume(self, nonce: str) -> bool:
58
+ async with self._lock:
59
+ if nonce in self._used:
60
+ return False
61
+ self._used.add(nonce)
62
+ return True
63
+
64
+
65
+ class X402Facilitator:
66
+ """x402 Payment Facilitator for Starknet.
67
+
68
+ Implements verify + settle for the x402 protocol using Chipi's paymaster.
69
+
70
+ Args:
71
+ sdk: ChipiSDK instance
72
+ bearer_token: Bearer token for Chipi API calls
73
+ nonce_store: Optional custom nonce store (defaults to in-memory)
74
+ """
75
+
76
+ def __init__(
77
+ self,
78
+ sdk: Any,
79
+ bearer_token: Optional[str] = None,
80
+ nonce_store: Optional[NonceStore] = None,
81
+ ):
82
+ self.sdk = sdk
83
+ self.bearer_token = bearer_token
84
+ self.nonce_store = nonce_store or InMemoryNonceStore()
85
+
86
+ async def averify(self, payment: PaymentPayload) -> VerifyResponse:
87
+ """Async verify a payment signature (doesn't execute transfer).
88
+
89
+ Checks: version, network, nonce replay, expiry, asset, amount, addresses, signature.
90
+ """
91
+ payload = payment.payload
92
+
93
+ # Check x402 version
94
+ if payment.x402_version != 1:
95
+ return VerifyResponse(isValid=False, invalidReason=f"Unsupported x402 version: {payment.x402_version}")
96
+
97
+ # Check scheme
98
+ if payment.scheme != "exact":
99
+ return VerifyResponse(isValid=False, invalidReason=f"Unsupported payment scheme: {payment.scheme}")
100
+
101
+ # Check network
102
+ if payment.network != "starknet-mainnet":
103
+ return VerifyResponse(isValid=False, invalidReason=f"Unsupported network: {payment.network}")
104
+
105
+ # Check nonce replay
106
+ if await self.nonce_store.ahas(payload.nonce):
107
+ return VerifyResponse(isValid=False, invalidReason="Nonce already used (replay)")
108
+
109
+ # Check expiry
110
+ now = int(time.time())
111
+ if payload.valid_until <= now:
112
+ return VerifyResponse(isValid=False, invalidReason="Payment has expired (validUntil in the past)")
113
+
114
+ # Validate asset is USDC
115
+ normalized_asset = payload.asset.lower().lstrip("0x").lstrip("0")
116
+ normalized_usdc = CONTRACT_ADDRESSES["USDC_MAINNET"].lower().lstrip("0x").lstrip("0")
117
+ if normalized_asset != normalized_usdc:
118
+ return VerifyResponse(isValid=False, invalidReason=f"Unsupported asset: {payload.asset}. Only USDC is accepted.")
119
+
120
+ # Validate amount is positive
121
+ try:
122
+ amount = int(payload.amount)
123
+ except (ValueError, TypeError):
124
+ return VerifyResponse(isValid=False, invalidReason="Invalid payment amount format")
125
+ if amount <= 0:
126
+ return VerifyResponse(isValid=False, invalidReason="Payment amount must be positive")
127
+
128
+ # Validate addresses
129
+ if not payload.from_address or not payload.to_address:
130
+ return VerifyResponse(isValid=False, invalidReason="Missing fromAddress or toAddress")
131
+
132
+ # Validate signature presence
133
+ # NOTE: Full SNIP-12 cryptographic verification requires reconstructing
134
+ # the typed data hash and verifying against from_address on-chain.
135
+ # Currently checks presence only; the paymaster rejects invalid signatures
136
+ # at settlement time. TODO: Add on-chain signature verification.
137
+ if not payload.signature.r or not payload.signature.s:
138
+ return VerifyResponse(isValid=False, invalidReason="Missing signature")
139
+
140
+ return VerifyResponse(isValid=True)
141
+
142
+ def verify(self, payment: PaymentPayload) -> VerifyResponse:
143
+ """Sync verify. Uses asyncio.run(); use averify() in async contexts."""
144
+ return asyncio.run(self.averify(payment))
145
+
146
+ async def asettle(self, payment: PaymentPayload) -> SettleResponse:
147
+ """Async settle a verified payment via paymaster.
148
+
149
+ Marks nonce as consumed BEFORE executing to prevent concurrent replays.
150
+ """
151
+ payload = payment.payload
152
+
153
+ # Verify first
154
+ verification = await self.averify(payment)
155
+ if not verification.is_valid:
156
+ return SettleResponse(success=False, errorReason=verification.invalid_reason)
157
+
158
+ # Atomically consume nonce (prevents concurrent replays)
159
+ consumed = await self.nonce_store.aconsume(payload.nonce)
160
+ if not consumed:
161
+ return SettleResponse(success=False, errorReason="Nonce already consumed (concurrent replay)")
162
+
163
+ try:
164
+ # Execute transfer via SDK
165
+ from .models.transaction import Call
166
+
167
+ tx_hash = await self.sdk.aexecute_transaction(
168
+ params={
169
+ "wallet": {"publicKey": payload.from_address},
170
+ "calls": [
171
+ Call(
172
+ contractAddress=payload.asset,
173
+ entrypoint="transfer",
174
+ calldata=[payload.to_address, payload.amount, "0x0"],
175
+ )
176
+ ],
177
+ },
178
+ bearer_token=self.bearer_token,
179
+ )
180
+
181
+ if not isinstance(tx_hash, str) or not tx_hash.strip():
182
+ return SettleResponse(
183
+ success=False,
184
+ errorReason="Settlement failed: invalid transaction hash returned by SDK",
185
+ )
186
+
187
+ return SettleResponse(
188
+ success=True,
189
+ transactionHash=tx_hash,
190
+ networkId="starknet-mainnet",
191
+ )
192
+ except Exception as e:
193
+ return SettleResponse(
194
+ success=False,
195
+ errorReason=f"Settlement failed: {str(e)}",
196
+ )
197
+
198
+ def settle(self, payment: PaymentPayload) -> SettleResponse:
199
+ """Sync settle. Uses asyncio.run(); use asettle() in async contexts."""
200
+ return asyncio.run(self.asettle(payment))
@@ -0,0 +1,280 @@
1
+ """x402 Payment Middleware for Python web frameworks.
2
+
3
+ Provides middleware/decorators for FastAPI and Flask to monetize API endpoints
4
+ using the x402 payment protocol on Starknet.
5
+ """
6
+
7
+ import json
8
+ import re
9
+ from functools import wraps
10
+ from typing import Any, Callable, Optional
11
+
12
+ from .constants import CONTRACT_ADDRESSES, TOKEN_DECIMALS
13
+ from .models.x402 import PaymentPayload, PaymentRequirement, X402PaymentConfig
14
+ from .x402_facilitator import X402Facilitator
15
+
16
+ _AMOUNT_RE = re.compile(r"^\d+(?:\.\d+)?$")
17
+
18
+
19
+ def _amount_to_base_units(amount: str, decimals: int) -> str:
20
+ """Convert human-readable amount to base units using string arithmetic (no float)."""
21
+ normalized = amount.strip()
22
+ if not _AMOUNT_RE.fullmatch(normalized):
23
+ raise ValueError(f"Invalid amount format: {amount!r}")
24
+ parts = normalized.split(".")
25
+ whole = parts[0]
26
+ frac = parts[1] if len(parts) > 1 else ""
27
+ if len(frac) > decimals:
28
+ raise ValueError(f"Too many decimal places in {amount!r}; max {decimals}")
29
+ frac_padded = (frac + "0" * decimals)[:decimals]
30
+ result = str(int(whole + frac_padded))
31
+ if result == "0":
32
+ raise ValueError(f"Amount resolves to zero base units: {amount!r}")
33
+ return result
34
+
35
+
36
+ def _build_payment_requirement(config: X402PaymentConfig, resource: str) -> dict:
37
+ """Build a payment requirement dict from config + request URL."""
38
+ amount_base_units = _amount_to_base_units(config.amount, TOKEN_DECIMALS["USDC"])
39
+
40
+ return {
41
+ "scheme": "exact",
42
+ "network": "starknet-mainnet",
43
+ "maxAmountRequired": amount_base_units,
44
+ "resource": resource,
45
+ "description": config.description,
46
+ "payTo": config.pay_to,
47
+ "maxTimeoutSeconds": 300,
48
+ "asset": CONTRACT_ADDRESSES["USDC_MAINNET"],
49
+ }
50
+
51
+
52
+ def _normalize_addr(addr: str) -> str:
53
+ """Normalize a Starknet address for comparison."""
54
+ return addr.lower().lstrip("0x").lstrip("0")
55
+
56
+
57
+ def _enforce_payment_policy(payment: PaymentPayload, config: X402PaymentConfig) -> Optional[str]:
58
+ """Validate payment matches endpoint config. Returns error reason or None."""
59
+ requirement = _build_payment_requirement(config, "")
60
+
61
+ # Check recipient
62
+ if _normalize_addr(payment.payload.to_address) != _normalize_addr(config.pay_to):
63
+ return f"Payment recipient mismatch: expected {config.pay_to}, got {payment.payload.to_address}"
64
+
65
+ # Check amount
66
+ try:
67
+ paid = int(payment.payload.amount)
68
+ required = int(requirement["maxAmountRequired"])
69
+ if paid < required:
70
+ return f"Payment amount insufficient: required {required}, got {paid}"
71
+ except (ValueError, TypeError):
72
+ return f"Invalid payment amount: {payment.payload.amount}"
73
+
74
+ # Check asset
75
+ if _normalize_addr(payment.payload.asset) != _normalize_addr(requirement["asset"]):
76
+ return f"Payment asset mismatch: expected USDC, got {payment.payload.asset}"
77
+
78
+ return None
79
+
80
+
81
+ def fastapi_x402_dependency(
82
+ facilitator: X402Facilitator,
83
+ config: X402PaymentConfig,
84
+ verify_only: bool = False,
85
+ ) -> Callable:
86
+ """FastAPI dependency for x402 payment verification.
87
+
88
+ Usage:
89
+ facilitator = X402Facilitator(sdk, bearer_token)
90
+ payment_config = X402PaymentConfig(amount="0.01", pay_to="0xADDRESS")
91
+
92
+ @app.get("/premium")
93
+ async def premium(request: Request, x402=Depends(fastapi_x402_dependency(facilitator, payment_config))):
94
+ return {"data": "premium content", "tx_hash": x402.get("tx_hash")}
95
+ """
96
+
97
+ async def verify_payment(request: Any) -> dict:
98
+ from fastapi import HTTPException
99
+
100
+ if request.method == "OPTIONS":
101
+ return {}
102
+
103
+ payment_header = request.headers.get("x-payment") or request.headers.get("X-PAYMENT")
104
+ resource = str(request.url)
105
+
106
+ if not payment_header:
107
+ requirement = _build_payment_requirement(config, resource)
108
+ raise HTTPException(
109
+ status_code=402,
110
+ detail={
111
+ "error": "Payment Required",
112
+ "paymentRequirement": requirement,
113
+ },
114
+ headers={"Payment-Required": json.dumps(requirement)},
115
+ )
116
+
117
+ try:
118
+ payment_data = json.loads(payment_header)
119
+ payment = PaymentPayload(**payment_data)
120
+ except (json.JSONDecodeError, Exception) as e:
121
+ raise HTTPException(
122
+ status_code=400,
123
+ detail={"error": f"Malformed X-PAYMENT header: {str(e)}"},
124
+ )
125
+
126
+ # Enforce payment policy before verify/settle
127
+ policy_error = _enforce_payment_policy(payment, config)
128
+ if policy_error:
129
+ raise HTTPException(
130
+ status_code=402,
131
+ detail={"error": "Payment policy violation", "reason": policy_error},
132
+ )
133
+
134
+ try:
135
+ verified = await facilitator.averify(payment)
136
+ if not verified.is_valid:
137
+ raise HTTPException(
138
+ status_code=402,
139
+ detail={
140
+ "error": "Payment verification failed",
141
+ "reason": verified.invalid_reason,
142
+ },
143
+ )
144
+
145
+ result: dict[str, Any] = {"payment": payment}
146
+
147
+ if not verify_only:
148
+ settled = await facilitator.asettle(payment)
149
+ if not settled.success:
150
+ raise HTTPException(
151
+ status_code=402,
152
+ detail={
153
+ "error": "Payment settlement failed",
154
+ "reason": settled.error_reason,
155
+ },
156
+ )
157
+ result["tx_hash"] = settled.transaction_hash
158
+ result["network_id"] = settled.network_id
159
+ except HTTPException:
160
+ raise
161
+ except Exception:
162
+ raise HTTPException(
163
+ status_code=500,
164
+ detail={"error": "Internal payment processing error"},
165
+ )
166
+
167
+ return result
168
+
169
+ return verify_payment
170
+
171
+
172
+ def flask_x402_required(
173
+ facilitator: X402Facilitator,
174
+ config: X402PaymentConfig,
175
+ verify_only: bool = False,
176
+ ) -> Callable:
177
+ """Flask decorator for x402 payment verification.
178
+
179
+ Usage:
180
+ facilitator = X402Facilitator(sdk, bearer_token)
181
+ payment_config = X402PaymentConfig(amount="0.01", pay_to="0xADDRESS")
182
+
183
+ @app.route("/premium")
184
+ @flask_x402_required(facilitator, payment_config)
185
+ def premium():
186
+ return jsonify({"data": "premium content"})
187
+ """
188
+
189
+ def decorator(f: Callable) -> Callable:
190
+ @wraps(f)
191
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
192
+ from flask import request, jsonify, make_response
193
+
194
+ if request.method == "OPTIONS":
195
+ return make_response("", 204)
196
+
197
+ payment_header = request.headers.get("X-PAYMENT") or request.headers.get("x-payment")
198
+ resource = request.url
199
+
200
+ if not payment_header:
201
+ requirement = _build_payment_requirement(config, resource)
202
+ response = make_response(
203
+ jsonify({"error": "Payment Required", "paymentRequirement": requirement}),
204
+ 402,
205
+ )
206
+ response.headers["Payment-Required"] = json.dumps(requirement)
207
+ return response
208
+
209
+ try:
210
+ payment_data = json.loads(payment_header)
211
+ payment = PaymentPayload(**payment_data)
212
+ except (json.JSONDecodeError, Exception) as e:
213
+ return jsonify({"error": f"Malformed X-PAYMENT header: {str(e)}"}), 400
214
+
215
+ # Enforce payment policy before verify/settle
216
+ policy_error = _enforce_payment_policy(payment, config)
217
+ if policy_error:
218
+ return jsonify({
219
+ "error": "Payment policy violation",
220
+ "reason": policy_error,
221
+ }), 402
222
+
223
+ try:
224
+ verified = facilitator.verify(payment)
225
+ if not verified.is_valid:
226
+ return jsonify({
227
+ "error": "Payment verification failed",
228
+ "reason": verified.invalid_reason,
229
+ }), 402
230
+
231
+ if not verify_only:
232
+ settled = facilitator.settle(payment)
233
+ if not settled.success:
234
+ return jsonify({
235
+ "error": "Payment settlement failed",
236
+ "reason": settled.error_reason,
237
+ }), 402
238
+
239
+ request.x402 = {
240
+ "tx_hash": settled.transaction_hash,
241
+ "payment": payment,
242
+ "network_id": settled.network_id,
243
+ }
244
+ else:
245
+ request.x402 = {"payment": payment}
246
+ except Exception:
247
+ return jsonify({
248
+ "error": "Internal payment processing error",
249
+ }), 500
250
+
251
+ return f(*args, **kwargs)
252
+
253
+ return wrapper
254
+
255
+ return decorator
256
+
257
+
258
+ def x402_middleware(
259
+ amount: str,
260
+ recipient: str,
261
+ facilitator: X402Facilitator,
262
+ *,
263
+ description: Optional[str] = None,
264
+ verify_only: bool = False,
265
+ ) -> Callable:
266
+ """Convenience wrapper that creates a FastAPI x402 dependency with flat params.
267
+
268
+ Usage::
269
+
270
+ facilitator = X402Facilitator(sdk, bearer_token)
271
+
272
+ @app.get("/premium")
273
+ async def premium(
274
+ request: Request,
275
+ x402=Depends(x402_middleware("0.01", "0xADDRESS", facilitator)),
276
+ ):
277
+ return {"data": "premium", "tx_hash": x402.get("tx_hash")}
278
+ """
279
+ config = X402PaymentConfig(amount=amount, pay_to=recipient, description=description)
280
+ return fastapi_x402_dependency(facilitator, config, verify_only=verify_only)