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.
- chipi_sdk/__init__.py +342 -0
- chipi_sdk/client.py +505 -0
- chipi_sdk/constants.py +171 -0
- chipi_sdk/encryption.py +179 -0
- chipi_sdk/errors.py +130 -0
- chipi_sdk/execute_paymaster.py +434 -0
- chipi_sdk/formatters.py +154 -0
- chipi_sdk/models/__init__.py +145 -0
- chipi_sdk/models/core.py +96 -0
- chipi_sdk/models/session.py +119 -0
- chipi_sdk/models/sku.py +28 -0
- chipi_sdk/models/sku_transaction.py +30 -0
- chipi_sdk/models/transaction.py +192 -0
- chipi_sdk/models/user.py +31 -0
- chipi_sdk/models/wallet.py +178 -0
- chipi_sdk/models/x402.py +117 -0
- chipi_sdk/py.typed +1 -0
- chipi_sdk/sdk.py +1021 -0
- chipi_sdk/sessions.py +836 -0
- chipi_sdk/sku_transactions.py +58 -0
- chipi_sdk/skus.py +93 -0
- chipi_sdk/transactions.py +447 -0
- chipi_sdk/users.py +92 -0
- chipi_sdk/validators.py +75 -0
- chipi_sdk/wallets.py +465 -0
- chipi_sdk/x402_client.py +207 -0
- chipi_sdk/x402_facilitator.py +200 -0
- chipi_sdk/x402_middleware.py +280 -0
- chipi_stack-2.0.0.dist-info/METADATA +366 -0
- chipi_stack-2.0.0.dist-info/RECORD +33 -0
- chipi_stack-2.0.0.dist-info/WHEEL +5 -0
- chipi_stack-2.0.0.dist-info/licenses/LICENSE +21 -0
- chipi_stack-2.0.0.dist-info/top_level.txt +1 -0
chipi_sdk/x402_client.py
ADDED
|
@@ -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)
|